Compare commits

..

1 commit

Author SHA1 Message Date
GuaGua
62b56351d6 fix: narrow floating button hover hit area 2026-03-27 16:20:54 -07:00
273 changed files with 6017 additions and 18527 deletions

View file

@ -1,193 +1,67 @@
---
name: extension-real-browser-testing
description: Test browser extensions in real browsers using built artifacts, Edge + Playwright automation, runtime-message triggering, DOM debugging, and truthful screenshot capture.
metadata:
author: read-frog
version: "1.1.0"
description: Test browser extensions in real browsers using built artifacts, unpacked installs, local repro pages, and browser DevTools automation. Use when reproducing or validating extension behavior in Chrome, Edge, Chromium, Brave, or Firefox, especially for content scripts, popups, options pages, hover/focus bugs, and browser-specific regressions.
---
# Extension Real Browser Testing
Use this skill when you need to validate extension behavior in a real browser instead of guessing from unit tests, jsdom, or static screenshots.
## When to use
## When to Use
Apply this skill when:
- the task requires validating an extension in a real browser, not just unit tests
- the bug only reproduces after `build`, `zip`, or unpacked installation
- the issue is browser-specific or version-specific
- you need to inspect live content script DOM, shadow DOM, popup state, or service-worker targets
- you need stronger evidence than component tests or jsdom can provide
- a UI PR needs screenshots rendered from the real extension behavior
- The task requires validating an extension in a real browser, not just unit tests
- The bug only reproduces after `build`, `zip`, or unpacked installation
- The issue is browser-specific or version-specific
- You need to inspect live content script DOM, shadow DOM, popup state, or DevTools targets
- You need stronger evidence than component tests or jsdom can provide
## Quick reference
## Quick Reference
| Topic | Reference |
|-------|-----------|
| Browser discovery, Edge launch patterns, unpacked extension loading | [references/launching.md](references/launching.md) |
| Real-browser verification workflow, debugging heuristics, read-frog capture recipe | [references/workflow.md](references/workflow.md) |
| Browser discovery, launching, unpacked extension loading | [references/launching.md](references/launching.md) |
| Real-browser verification workflow and debugging heuristics | [references/workflow.md](references/workflow.md) |
## General workflow
## Default Workflow
1. Build the extension artifact you will actually test.
2. Confirm the build output directory and manifest exist.
3. Start a minimal local repro page when the bug does not need a third-party site.
4. Launch a fresh browser profile with the unpacked extension loaded.
5. Verify the extension really loaded before testing.
6. Trigger the real extension flow.
7. Inspect actual DOM/runtime state after reproduction.
8. Capture screenshots only after you can prove the claimed state.
9. Clean up temporary browser profiles, servers, and debugging processes after the run.
5. Verify the extension really loaded before testing:
Chromium-family: confirm the extension service worker or extension page appears in the DevTools target list.
6. Automate through DevTools when possible instead of relying on visual guesses.
7. Inspect actual DOM state after reproduction:
open/closed attributes, mounted/unmounted, opacity, visibility, pointer-events, relatedTarget, active element.
8. Clean up temporary browser profiles, servers, and debugging processes after the run.
## Rules
- Test the built artifact that matches the user report. Do not assume `dev` behavior proves anything about `build`.
- Prefer unpacked extension installs from `.output/<browser>-mv3` or the equivalent build directory.
- Use a fresh profile for every automation run so old extension state does not contaminate the result.
- Use a fresh `--user-data-dir` for browser launches so old extension state does not contaminate the result.
- Keep browser-specific fixes and conclusions scoped. Do not generalize from one browser to all browsers without evidence.
- If the issue is isolated to extension overlays or content scripts, prefer fixing the extension-local wrapper layer before touching shared primitives used by options or unrelated pages.
- Node existence alone is weak evidence. A tooltip, popover, or dialog may remain mounted while already closed. Always inspect visual state too.
- When screenshots are for review, raw before/after captures are the source of truth.
- Never present a stitched comparison graphic as if it were a raw browser screenshot.
- When checking close behavior, distinguish these states:
- Trigger never received leave/blur
- Root state never closed
- Root closed, but visual state remained visible
- If browser automation conflicts with an already-running GUI session on macOS, relaunch with `open -na ... --args ...` to force a fresh instance.
## Preferred automation path on this macOS setup
For this repository and machine, Edge + local Playwright was the most reliable path for unpacked-extension automation.
Important environment lessons:
- Chrome may appear to load an unpacked extension but still behave inconsistently under automation in this environment.
- Edge worked reliably with Playwright `launchPersistentContext` and a fresh profile.
- Reading `chrome.storage.local` from the Playwright service-worker target was unreliable here.
- Opening the real popup/options extension page and evaluating `chrome.*` there was reliable.
If Chrome behaves strangely, switch to Edge instead of continuing to guess.
## Read-frog page-translation workflow
For read-frog page translation, a reliable automation path was:
1. Open a real content page first.
2. Open `chrome-extension://<id>/popup.html`.
3. From the popup page, locate the actual content tab.
4. Explicitly set config in `chrome.storage.local` instead of assuming defaults.
5. Trigger the same runtime message used by the extension in production.
6. Wait for `.read-frog-spinner` nodes and record live DOM evidence.
7. Take the raw screenshot while spinner nodes are still present.
8. Keep waiting until translated wrapper nodes contain Chinese text to prove the run was real.
Example setup from the popup page:
```js
const setup = await popup.evaluate(async ({ targetUrl }) => {
const tab = (await chrome.tabs.query({})).find(item => item.url === targetUrl);
if (!tab?.id) throw new Error(`Could not find tab for ${targetUrl}`);
const current = await chrome.storage.local.get('config');
const config = current.config ?? {};
config.language = {
...(config.language ?? {}),
sourceCode: 'auto',
targetCode: 'cmn',
};
config.translate = {
...(config.translate ?? {}),
providerId: 'microsoft-translate-default',
mode: 'bilingual',
page: {
...(config.translate?.page ?? {}),
range: 'all',
},
requestQueueConfig: {
...(config.translate?.requestQueueConfig ?? {}),
rate: 1,
capacity: 1,
},
batchQueueConfig: {
...(config.translate?.batchQueueConfig ?? {}),
maxItemsPerBatch: 1,
maxCharactersPerBatch: 160,
},
};
await chrome.storage.local.set({ config });
await chrome.runtime.sendMessage({
id: Date.now(),
type: 'tryToSetEnablePageTranslationByTabId',
data: { tabId: tab.id, enabled: true },
timestamp: Date.now(),
});
return {
tabId: tab.id,
targetCode: config.language.targetCode,
};
}, { targetUrl: page.url() });
```
Good loading-time evidence for read-frog:
```js
await page.waitForFunction(
() => document.querySelectorAll('.read-frog-spinner').length >= 4,
null,
{ timeout: 45000 },
);
const loadingEvidence = await page.evaluate(() => ({
spinnerCount: document.querySelectorAll('.read-frog-spinner').length,
sampleSpinnerStyles: Array.from(document.querySelectorAll('.read-frog-spinner'))
.slice(0, 3)
.map(node => node.getAttribute('style')),
}));
```
Good completion-time evidence for read-frog:
```js
await page.waitForFunction(
() => {
const wrappers = Array.from(document.querySelectorAll('.read-frog-translated-content-wrapper'));
return wrappers.some(node => /[\u3400-\u9FFF]/.test(node.textContent || ''));
},
null,
{ timeout: 120000 },
);
```
## Screenshot policy
For UI PRs:
- keep raw before and raw after screenshots
- optionally add crops or a comparison board for reviewer convenience
- clearly label any composite as a composite built from raw screenshots
Important note from this workflow:
- a raw full-page screenshot can contain real loading spinners that are still hard to notice because they are tiny
- when reviewers need help seeing the difference, supplement the raw screenshots with zoomed crops, but do not replace the raw evidence
## What to capture
## What to Capture
For reproducible browser bugs, collect at least:
- browser name and version
- extension build artifact path
- whether the bug reproduces in `dev`, `build`, unpacked install, or store install
- exact page URL or minimal repro page
- DOM/runtime evidence after reproduction
- screenshot paths or hosted URLs
- if needed, a short event sequence: hover/focus/leave/openChange/close
- Browser name and version
- Extension version or build artifact path
- Whether the bug reproduces in `dev`, `build`, unpacked install, or store install
- Exact page URL or minimal repro page
- DOM evidence after reproduction
- If needed, a short event sequence: hover/focus/leave/openChange/close
## Common pitfalls
## Common Pitfalls
- testing the wrong profile and reading stale extension state
- assuming a popup or tooltip is still "open" just because the node still exists
- assuming a shortcut failure proves the feature is broken, when the runtime message path may still work
- using a fake or stitched screenshot and then describing it as raw evidence
- ignoring unrelated popup errors that may coexist with a still-working content-page flow
- pushing content-script-specific workarounds into shared primitives without evidence that every surface needs them
## References
- See `references/launching.md` for exact Edge launch patterns.
- See `references/workflow.md` for the real-browser checklist, debugging heuristics, and read-frog capture recipe.
- Testing the wrong profile and reading stale extension state
- Assuming a tooltip is still "open" just because the node still exists
- Assuming a popup is closed just because logic reported `open=false`
- Letting the tooltip or popup overlay itself become the mouse hit target during hover debugging
- Editing shared browser UI primitives when the bug is specific to extension content injected into pages

View file

@ -1,8 +1,8 @@
# Browser Launching
## Chromium-family browsers
## Chromium-Family Browsers
Prefer Edge, Chrome, Chromium, or Brave when extension automation or DevTools Protocol access is needed.
Prefer Chrome, Edge, Chromium, or Brave when DevTools Protocol automation is needed.
### Discovery
@ -19,49 +19,9 @@ Typical Linux commands:
- `chromium`
- `brave-browser`
## Preferred path here: Edge + Playwright
### Launch Pattern
Preferred browser for this workflow in this environment.
```js
import fs from 'node:fs';
import { chromium } from '/Users/frog/.hermes/hermes-agent/node_modules/playwright/index.mjs';
const extensionPath = '/ABS/PATH/TO/.output/chrome-mv3';
const userDataDir = '/tmp/extension-edge-profile';
fs.rmSync(userDataDir, { recursive: true, force: true });
const context = await chromium.launchPersistentContext(userDataDir, {
executablePath: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
headless: false,
viewport: { width: 1440, height: 1200 },
args: [
'--no-first-run',
'--no-default-browser-check',
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
```
## Verify the MV3 worker
```js
let worker = context.serviceWorkers()[0];
if (!worker) {
worker = await context.waitForEvent('serviceworker', { timeout: 30000 });
}
const extensionId = new URL(worker.url()).host;
```
Good signals:
- the content page is present in `context.pages()`
- the MV3 service worker is present in `context.serviceWorkers()`
- the worker URL resolves to your extension ID
## Alternative manual launch
Useful when you want a browser window plus a remote debugging port for manual inspection:
Use a fresh profile and load the unpacked build artifact directly.
```bash
open -na '/Applications/Microsoft Edge.app' --args \
@ -69,26 +29,40 @@ open -na '/Applications/Microsoft Edge.app' --args \
--user-data-dir=/tmp/ext-test-edge-profile \
--no-first-run \
--no-default-browser-check \
--disable-extensions-except='/abs/path/.output/chrome-mv3' \
--load-extension='/abs/path/.output/chrome-mv3' \
--disable-background-networking \
--disable-sync \
--disable-extensions-except='/abs/path/.output/edge-mv3' \
--load-extension='/abs/path/.output/edge-mv3' \
http://127.0.0.1:8123/
```
Then inspect targets with:
Use the same pattern for Chrome, Chromium, or Brave by swapping the app path.
### Verify the Extension Loaded
Query the DevTools target list:
```bash
curl -s http://127.0.0.1:9226/json/list
```
## Why not Browserbase/browser tool?
Good signals:
- target for your test page
- service worker target like `chrome-extension://<id>/background.js`
The browser tool cannot load a local unpacked extension, so it is not suitable for this workflow.
## Firefox
## Why not Chrome first?
Firefox may not expose the same DevTools Protocol workflow. Prefer:
- manual repro with a fresh profile
- Playwright or browser-native debugging if the environment already supports it
Chrome can be fine in general, but on this machine Edge was more reliable for fresh-profile unpacked-extension testing. If Chrome behaves inconsistently, switch to Edge instead of brute-forcing it.
Still follow the same core principles:
- fresh profile
- built artifact
- verify the extension actually loaded
- collect DOM evidence, not only screenshots
## Local repro pages
## Local Repro Pages
When the bug does not require a production site, prefer a minimal local page.
@ -98,8 +72,8 @@ Examples:
Benefits:
- removes third-party page variables
- makes reproduction coordinates deterministic
- avoids unnecessary network flakiness
- makes selection and hover coordinates deterministic
- avoids network flakiness
## Cleanup

View file

@ -1,18 +1,34 @@
# Workflow
## Real-browser repro checklist
## Real-Browser Repro Checklist
1. Build the browser-specific artifact.
2. Verify the manifest in the expected output directory.
3. Launch a fresh browser instance with the unpacked extension.
4. Confirm the worker/pages before continuing.
4. Confirm DevTools targets before continuing.
5. Reproduce the issue.
6. Read live DOM/runtime state after the repro, not just screenshots.
6. Read live DOM state after the repro, not just screenshots.
7. If needed, add temporary instrumentation to the extension-local layer and rebuild.
## How to reason about UI bugs
## DevTools Automation Pattern
### Tooltip / popover bugs
For Chromium-family browsers, a minimal Node script using:
- `fetch` to read `/json/list`
- `WebSocket` to connect to `webSocketDebuggerUrl`
- `Runtime.evaluate`
- `Input.dispatchMouseEvent`
is usually enough.
Use it for:
- selecting text
- clicking toolbar buttons
- hovering triggers
- checking popup and tooltip state
## How to Reason About UI Bugs
### Tooltip / Popover Bugs
Do not stop at "the node still exists."
@ -30,7 +46,7 @@ Important distinction:
- If `onOpenChange(false)` never fires, the event chain is wrong.
- If `onOpenChange(false)` fires but the element remains visible, the closed-state styling or unmount flow is wrong.
### Hover bugs
### Hover Bugs
Inspect `relatedTarget` on leave events.
@ -38,70 +54,19 @@ If the pointer leaves the trigger and lands on the tooltip's own overlay or posi
- the tooltip overlay may need `pointer-events-none`
- the extension-local tooltip wrapper may need a stronger closed-state style
### Build-only bugs
### Build-Only Bugs
When a bug appears only after `build`:
- reproduce against the built artifact first
- do not assume the dev server path is relevant
- compare dev/build only after you have real evidence from the built version
## Read-frog page-translation capture recipe
## Fix Scope Guidance
1. Build the extension.
2. Launch Edge with a fresh profile and the unpacked build.
3. Open the target content page first.
4. Open `chrome-extension://<id>/popup.html`.
5. From the popup page, use `chrome.storage.local` to set:
- `language.targetCode = 'cmn'`
- `translate.providerId = 'microsoft-translate-default'`
- `translate.page.range = 'all'`
- `translate.requestQueueConfig = { rate: 1, capacity: 1 }`
- `translate.batchQueueConfig = { maxItemsPerBatch: 1, maxCharactersPerBatch: 160 }`
6. Trigger page translation by sending:
Prefer the narrowest layer that actually owns the problem:
```js
await chrome.runtime.sendMessage({
id: Date.now(),
type: 'tryToSetEnablePageTranslationByTabId',
data: { tabId, enabled: true },
timestamp: Date.now(),
});
```
- Shared primitive only if the same bug exists across unrelated surfaces
- Extension-local wrapper if the bug is specific to content-script overlays or injected UI
- Feature-local component if only one trigger path is affected
7. Wait for loading evidence:
```js
await page.waitForFunction(
() => document.querySelectorAll('.read-frog-spinner').length >= 4,
null,
{ timeout: 45000 },
);
```
8. Record DOM evidence at capture time:
- spinner count
- sample inline spinner style strings
9. Take the raw screenshot.
10. Keep waiting until translated wrapper nodes contain Chinese text to prove the run was real.
## Honesty rules for screenshots
- Raw before and raw after screenshots are the source of truth.
- A crop is a crop, not a raw full-page screenshot.
- A stitched comparison board is a comparison graphic, not a raw screenshot.
- If the element is tiny and hard to see in a full-page shot, keep the raw shot and add a labeled crop as supplemental evidence.
## Common signals for read-frog
Loading-time signals:
- `.read-frog-spinner`
- inline spinner style strings
Completion-time signals:
- `.read-frog-translated-content-wrapper`
- Chinese characters in translated text
- translated page title
## Common pitfall seen in this workflow
The popup could show a recovery-mode error like `e?.trim is not a function` while the content-page translation flow still worked. Treat that as a separate issue and verify the actual target behavior directly.
When a shared primitive is used by safe surfaces like options or popup pages, be careful not to push a content-script-specific workaround into that shared layer unless you have evidence that every surface needs it.

View file

@ -1,5 +0,0 @@
---
"@read-frog/extension": patch
---
fix(options): preserve focused provider options drafts

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": minor
---
feat(extension): add save to notebase workflow

View file

@ -1,5 +0,0 @@
---
"@read-frog/extension": patch
---
fix(page-translation): re-walk revealed accordion content

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(extension): guard notebase beta access

View file

@ -1,174 +0,0 @@
---
description: Test browser extensions in a real browser using Edge + Playwright, including unpacked extension injection, runtime-message triggering, DOM debugging, and truthful screenshot capture.
allowed-tools: Bash(node:*), Bash(pnpm:*), Bash(git:*), Bash(python3:*), Bash(gh:*), Read, Glob, Write, Edit
---
# Extension Real Browser Testing
Use this skill when you need to debug or verify extension behavior in a real browser instead of guessing from unit tests or static screenshots.
## Core rules
1. Test the built artifact, not an imagined dev state.
2. Use local Playwright via `node`, not Browserbase/browser tool automation.
3. Prefer Edge on this macOS setup when Chrome behaves inconsistently.
4. Use a fresh browser profile every run.
5. Prove behavior with live DOM/runtime evidence in addition to screenshots.
6. Never present a stitched comparison graphic as if it were a raw browser screenshot.
## Workflow
### 1. Build the unpacked extension
```bash
pnpm build
```
Typical output:
```text
.output/chrome-mv3
```
### 2. Launch Edge with the unpacked extension
```js
import fs from 'node:fs';
import { chromium } from '/Users/frog/.hermes/hermes-agent/node_modules/playwright/index.mjs';
const extensionPath = '/ABS/PATH/TO/.output/chrome-mv3';
const userDataDir = '/tmp/extension-edge-profile';
fs.rmSync(userDataDir, { recursive: true, force: true });
const context = await chromium.launchPersistentContext(userDataDir, {
executablePath: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
headless: false,
viewport: { width: 1440, height: 1200 },
args: [
'--no-first-run',
'--no-default-browser-check',
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
let worker = context.serviceWorkers()[0];
if (!worker) {
worker = await context.waitForEvent('serviceworker', { timeout: 30000 });
}
const extensionId = new URL(worker.url()).host;
```
### 3. Inspect or mutate state from popup/options
Important lesson from this workflow:
- reading `chrome.storage.local` from the service-worker target was unreliable here
- opening the real popup/options extension page and evaluating `chrome.*` there was reliable
```js
await popup.goto(`chrome-extension://${extensionId}/popup.html`)
await options.goto(`chrome-extension://${extensionId}/options.html`)
```
### 4. Trigger the real extension flow
For read-frog page translation, a reliable approach was:
- open a real content page first
- locate the real content tab from the popup page
- set `language.targetCode = 'cmn'`
- set the page-translation queue slow enough that loading is visible
- send the runtime message used by the extension
```js
const setup = await popup.evaluate(async ({ targetUrl }) => {
const tab = (await chrome.tabs.query({})).find(item => item.url === targetUrl);
if (!tab?.id) throw new Error(`Could not find tab for ${targetUrl}`);
const current = await chrome.storage.local.get('config');
const config = current.config ?? {};
config.language = {
...(config.language ?? {}),
sourceCode: 'auto',
targetCode: 'cmn',
};
config.translate = {
...(config.translate ?? {}),
providerId: 'microsoft-translate-default',
mode: 'bilingual',
page: {
...(config.translate?.page ?? {}),
range: 'all',
},
requestQueueConfig: {
...(config.translate?.requestQueueConfig ?? {}),
rate: 1,
capacity: 1,
},
batchQueueConfig: {
...(config.translate?.batchQueueConfig ?? {}),
maxItemsPerBatch: 1,
maxCharactersPerBatch: 160,
},
};
await chrome.storage.local.set({ config });
await chrome.runtime.sendMessage({
id: Date.now(),
type: 'tryToSetEnablePageTranslationByTabId',
data: { tabId: tab.id, enabled: true },
timestamp: Date.now(),
});
return {
tabId: tab.id,
targetCode: config.language.targetCode,
};
}, { targetUrl: page.url() });
```
### 5. Wait for live DOM evidence
```js
await page.waitForFunction(
() => document.querySelectorAll('.read-frog-spinner').length >= 4,
null,
{ timeout: 45000 },
);
const loadingEvidence = await page.evaluate(() => ({
spinnerCount: document.querySelectorAll('.read-frog-spinner').length,
sampleSpinnerStyles: Array.from(document.querySelectorAll('.read-frog-spinner'))
.slice(0, 3)
.map(node => node.getAttribute('style')),
}));
```
Then verify completion:
```js
await page.waitForFunction(
() => {
const wrappers = Array.from(document.querySelectorAll('.read-frog-translated-content-wrapper'));
return wrappers.some(node => /[\u3400-\u9FFF]/.test(node.textContent || ''));
},
null,
{ timeout: 120000 },
);
```
### 6. Screenshot rules
- Keep raw before and raw after screenshots.
- If the spinner is tiny in a full-page shot, add a crop as supplemental evidence.
- A crop is not a raw screenshot.
- A stitched board is not a raw screenshot.
## Pitfalls
- Chrome can behave inconsistently for unpacked-extension automation on this machine; switch to Edge when needed.
- Do not assume service-worker evaluation can read all needed state.
- A tiny spinner can be present in a correct raw full-page screenshot while still being hard to see at first glance.
- An extension page can show an unrelated recovery-mode or config error while the target content-script flow still works; verify the real target behavior directly.
- If keyboard shortcuts are flaky in automation, trigger the equivalent runtime message or popup action instead.

View file

@ -1,48 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "read-frog"
[setup]
script = '''
set -eu
copy_env() {
rel="$1"
example_rel="$2"
src="$CODEX_SOURCE_TREE_PATH/$rel"
dst="$CODEX_WORKTREE_PATH/$rel"
example="$CODEX_SOURCE_TREE_PATH/$example_rel"
mkdir -p "$(dirname "$dst")"
if [ -f "$src" ]; then
cp "$src" "$dst"
echo "copied $rel from source tree"
elif [ -f "$example" ]; then
cp "$example" "$dst"
echo "created $rel from example"
else
echo "skipped $rel (no source or example found)"
fi
}
copy_source_file() {
rel="$1"
src="$CODEX_SOURCE_TREE_PATH/$rel"
dst="$CODEX_WORKTREE_PATH/$rel"
mkdir -p "$(dirname "$dst")"
if [ -f "$src" ]; then
cp "$src" "$dst"
echo "copied $rel from source tree"
else
echo "skipped $rel (no source found)"
fi
}
copy_env ".env.development" ".env.example"
copy_source_file "web-ext.config.ts"
'''

View file

@ -1,16 +0,0 @@
# Optional overrides for the extension's runtime URLs/domains.
# By default the extension uses production readfrog.app values.
# When WXT_USE_LOCAL_PACKAGES=true, it instead falls back to localhost values.
# Values must already be canonical:
# - URLs must not end with a trailing slash
# - origins/domains are comma-separated with no spaces
#
# WXT_API_URL=https://api.readfrog.app
# WXT_WEBSITE_URL=https://www.readfrog.app
# WXT_OFFICIAL_SITE_ORIGINS=https://readfrog.app,https://www.readfrog.app
# WXT_AUTH_COOKIE_DOMAINS=readfrog.app
#
# Required in poduction:
# WXT_GOOGLE_CLIENT_ID=your-google-client-id
# WXT_POSTHOG_HOST=https://us.i.posthog.com
# WXT_POSTHOG_API_KEY=phc_your_posthog_key

View file

@ -1,14 +0,0 @@
export function isBotAuthor(user = null) {
const login = typeof user?.login === "string" ? user.login.trim() : ""
const type = typeof user?.type === "string" ? user.type : ""
return type === "Bot" || login.toLowerCase().endsWith("[bot]")
}
export function getBotAuthorSkipReason(user = null) {
if (!isBotAuthor(user))
return null
const login = typeof user?.login === "string" ? user.login.trim() : ""
return login ? `bot-authored PR by @${login}` : "bot-authored PR"
}

View file

@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest"
import { getBotAuthorSkipReason, isBotAuthor } from "./bot-author.js"
describe("isBotAuthor", () => {
it("treats GitHub Bot accounts as bots", () => {
expect(isBotAuthor({
login: "dependabot[bot]",
type: "Bot",
})).toBe(true)
})
it("falls back to the login suffix when the type is not marked as Bot", () => {
expect(isBotAuthor({
login: "renovate[bot]",
type: "User",
})).toBe(true)
})
it("does not flag human authors", () => {
expect(isBotAuthor({
login: "mengxi-ream",
type: "User",
})).toBe(false)
})
})
describe("getBotAuthorSkipReason", () => {
it("returns a skip reason for bot-authored pull requests", () => {
expect(getBotAuthorSkipReason({
login: "dependabot[bot]",
type: "Bot",
})).toBe("bot-authored PR by @dependabot[bot]")
})
it("returns null for human-authored pull requests", () => {
expect(getBotAuthorSkipReason({
login: "mengxi-ream",
type: "User",
})).toBeNull()
})
})

View file

@ -1,97 +0,0 @@
import { createHash } from "node:crypto"
import { BUCKET_TITLES, COMMENT_MARKER_HTML, FINGERPRINT_MARKER_PREFIX, POLICY } from "./config.js"
function formatMonths(createdAt) {
if (!createdAt)
return "unknown"
const timestamp = new Date(createdAt).getTime()
if (Number.isNaN(timestamp))
return "unknown"
const months = Math.max(0, Math.floor((Date.now() - timestamp) / 2.628e9))
return `${months} month${months === 1 ? "" : "s"}`
}
function formatRepositoryList(repositories) {
if (repositories.length === 0)
return "none"
return repositories
.map(repository => `${repository.nameWithOwner} (${repository.stargazerCount})`)
.join(", ")
}
function summarizeTopRepositories(repositories) {
const stars = repositories.map(repository => repository.stargazerCount)
return {
list: formatRepositoryList(repositories),
max: stars.length > 0 ? Math.max(...stars) : 0,
total: stars.reduce((sum, starCount) => sum + starCount, 0),
}
}
function buildContent({ owner, repo, pullRequest, author, metrics, score, plan }) {
const bucketTitle = BUCKET_TITLES[score.bucket]
const trustLabel = plan.targetTrustLabel ?? "none"
const reviewStatus = plan.needsMaintainerReview ? "required" : "not required"
const includedRepositories = summarizeTopRepositories(metrics.topRepositories)
const intro = `This score estimates contributor familiarity with \`${owner}/${repo}\` using public GitHub signals. It is advisory only and does not block merges automatically.`
return [
"## Contributor trust score",
"",
`**${score.total}/100** — ${bucketTitle}`,
"",
intro,
"",
"**Outcome**",
`- PR: #${pullRequest.number}${pullRequest.title}`,
`- Author: @${author.login}`,
`- Trust label: \`${trustLabel}\``,
`- Maintainer review: ${reviewStatus}`,
`- Override label: add \`${POLICY.overrideLabel}\` to stop future trust automation updates`,
"",
"**Score breakdown**",
"",
"| Dimension | Score | Signals |",
"| --- | ---: | --- |",
`| Repo familiarity | ${score.repoFamiliarity}/35 | commits in repo, merged PRs, reviews |`,
`| Community standing | ${score.communityStanding}/25 | account age, followers, repo role |`,
`| OSS influence | ${score.ossInfluence}/20 | stars on owned non-fork repositories |`,
`| PR track record | ${score.prTrackRecord}/20 | merge rate across resolved PRs in this repo |`,
"",
"**Signals used**",
`- Repo commits: ${metrics.commitsInRepo} (author commits reachable from the repository default branch)`,
`- Repo PR history: merged ${metrics.mergedPrs}, open ${metrics.openPrs}, closed-unmerged ${metrics.closedPrs}`,
`- Repo reviews: ${metrics.reviews}`,
`- PR changed lines: ${(Number(pullRequest.additions) || 0) + (Number(pullRequest.deletions) || 0)} (+${Number(pullRequest.additions) || 0} / -${Number(pullRequest.deletions) || 0})`,
`- Repo permission: ${metrics.repoPermission ?? "none"}`,
`- Followers: ${metrics.followers}`,
`- Account age: ${formatMonths(metrics.accountCreated)}`,
`- Owned non-fork repos considered: max ${includedRepositories.max}, total ${includedRepositories.total} (${includedRepositories.list})`,
"",
"**Policy**",
`- Low-score review threshold: < ${POLICY.lowScoreThreshold}`,
`- Auto-close: score < ${POLICY.autoCloseBelowScore} and changed lines > ${POLICY.autoCloseAboveChangedLines}`,
`- Policy version: \`${POLICY.version}\``,
"",
`_${pullRequest.state === "closed" ? "Manually re-evaluated on a closed PR." : "Updated automatically when the PR changes or when a maintainer reruns the workflow."}_`,
].join("\n")
}
export function buildTrustComment({ owner, repo, pullRequest, author, metrics, score, plan }) {
const content = buildContent({ owner, repo, pullRequest, author, metrics, score, plan })
const fingerprint = createHash("sha256").update(content).digest("hex").slice(0, 12)
return {
fingerprint,
body: [
COMMENT_MARKER_HTML,
`<!-- ${FINGERPRINT_MARKER_PREFIX}${fingerprint} -->`,
content,
].join("\n"),
}
}

View file

@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest"
import { buildTrustComment } from "./comment-template.js"
describe("buildTrustComment", () => {
it("shows named non-fork repositories and excluded fork repos", () => {
const comment = buildTrustComment({
author: {
login: "kilidoc",
name: "Kilidoc",
type: "User",
url: "https://github.com/kilidoc",
},
metrics: {
accountCreated: "2019-01-01T00:00:00Z",
closedPrs: 0,
commitsInRepo: 14,
followers: 3,
mergedPrs: 1,
openPrs: 0,
repoPermission: "write",
reviews: 1,
topRepositories: [
{
isFork: false,
nameWithOwner: "kilidoc/browser-tools",
parentNameWithOwner: null,
stargazerCount: 42,
},
],
},
owner: "mengxi-ream",
plan: {
needsMaintainerReview: false,
targetTrustLabel: "contrib-trust:trusted",
},
pullRequest: {
additions: 820,
deletions: 245,
number: 1242,
state: "open",
title: "fix: storage false value reset and backup delete dialog not showing",
},
repo: "read-frog",
score: {
bucket: "trusted",
communityStanding: 6,
exemptReason: null,
ossInfluence: 3,
prTrackRecord: 20,
repoFamiliarity: 14,
total: 43,
},
})
expect(comment.body).toContain("stars on owned non-fork repositories")
expect(comment.body).toContain("Repo commits: 14")
expect(comment.body).toContain("PR changed lines: 1065 (+820 / -245)")
expect(comment.body).toContain("Repo permission: write")
expect(comment.body).toContain("Auto-close: score < 20 and changed lines > 1000")
expect(comment.body).toContain("Owned non-fork repos considered: max 42, total 42 (kilidoc/browser-tools (42))")
expect(comment.body).not.toContain("Fork repos excluded from OSS influence")
expect(comment.body).not.toContain("Public repos:")
})
})

View file

@ -1,82 +0,0 @@
export const COMMENT_MARKER = "contributor-trust-score:v1"
export const COMMENT_MARKER_HTML = `<!-- ${COMMENT_MARKER} -->`
export const FINGERPRINT_MARKER_PREFIX = "contributor-trust-fingerprint:"
export const MANAGED_COMMENT_AUTHOR = "github-actions[bot]"
export const TRUST_LABEL_PREFIX = "contrib-trust:"
export const POLICY = Object.freeze({
version: "v1.1",
lowScoreThreshold: 30,
autoCloseBelowScore: 20,
autoCloseAboveChangedLines: 1000,
overrideLabel: "trust-check:skip",
needsMaintainerReviewLabel: "needs-maintainer-review",
adminLabel: `${TRUST_LABEL_PREFIX}admin`,
repoFamiliarityBonusPermissions: ["admin", "maintain", "write"],
reviewQueryPageSize: 50,
})
export const TRUST_BUCKETS = Object.freeze({
HIGHLY_TRUSTED: "highly-trusted",
TRUSTED: "trusted",
MODERATE: "moderate",
NEW: "new",
})
export const BUCKET_LABELS = Object.freeze({
[TRUST_BUCKETS.HIGHLY_TRUSTED]: `${TRUST_LABEL_PREFIX}highly-trusted`,
[TRUST_BUCKETS.TRUSTED]: `${TRUST_LABEL_PREFIX}trusted`,
[TRUST_BUCKETS.MODERATE]: `${TRUST_LABEL_PREFIX}moderate`,
[TRUST_BUCKETS.NEW]: `${TRUST_LABEL_PREFIX}new`,
})
export const BUCKET_TITLES = Object.freeze({
[TRUST_BUCKETS.HIGHLY_TRUSTED]: "Highly trusted",
[TRUST_BUCKETS.TRUSTED]: "Trusted",
[TRUST_BUCKETS.MODERATE]: "Moderate",
[TRUST_BUCKETS.NEW]: "New contributor",
})
export const LABEL_DEFINITIONS = Object.freeze({
[BUCKET_LABELS[TRUST_BUCKETS.HIGHLY_TRUSTED]]: {
color: "0e8a16",
description: "PR author trust score is 80-100.",
},
[BUCKET_LABELS[TRUST_BUCKETS.TRUSTED]]: {
color: "1d76db",
description: "PR author trust score is 60-79.",
},
[BUCKET_LABELS[TRUST_BUCKETS.MODERATE]]: {
color: "fbca04",
description: "PR author trust score is 30-59.",
},
[BUCKET_LABELS[TRUST_BUCKETS.NEW]]: {
color: "d4c5f9",
description: "PR author trust score is 0-29.",
},
[POLICY.adminLabel]: {
color: "5319e7",
description: "Legacy trust label kept for cleanup of old automation runs.",
},
[POLICY.needsMaintainerReviewLabel]: {
color: "b60205",
description: "Contributor trust automation recommends maintainer review.",
},
[POLICY.overrideLabel]: {
color: "bfd4f2",
description: "Skip contributor trust automation on this PR.",
},
})
export const MANAGED_TRUST_LABELS = Object.freeze([
...Object.values(BUCKET_LABELS),
POLICY.adminLabel,
])
export function bucketToLabel(bucket) {
return BUCKET_LABELS[bucket]
}
export function bucketToTitle(bucket) {
return BUCKET_TITLES[bucket]
}

View file

@ -1,491 +0,0 @@
import { POLICY } from "./config.js"
const API_BASE_URL = "https://api.github.com"
const LINK_HEADER_ENTRY_PATTERN = /^<([^>]+)>;\s*rel="([^"]+)"$/
class GitHubApiError extends Error {
constructor(message, details = {}) {
super(message)
this.name = "GitHubApiError"
this.details = details
}
}
function buildHeaders(token, extraHeaders = {}) {
return {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "read-frog-contributor-trust",
...extraHeaders,
}
}
async function parseResponse(response) {
const text = await response.text()
if (!text)
return null
try {
return JSON.parse(text)
}
catch {
return text
}
}
function buildErrorMessage(method, path, response, payload) {
const suffix = payload && typeof payload === "object" && "message" in payload
? `: ${payload.message}`
: ""
return `${method} ${path} failed with ${response.status} ${response.statusText}${suffix}`
}
export async function apiRequest(token, path, { body, headers, method = "GET" } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: buildHeaders(token, headers),
body: body === undefined ? undefined : JSON.stringify(body),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage(method, path, response, payload), {
path,
payload,
response,
})
}
return payload
}
export async function graphqlRequest(token, query, variables) {
const response = await fetch(`${API_BASE_URL}/graphql`, {
method: "POST",
headers: buildHeaders(token),
body: JSON.stringify({ query, variables }),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage("POST", "/graphql", response, payload), {
payload,
response,
})
}
if (payload.errors?.length) {
throw new GitHubApiError(payload.errors.map(error => error.message).join("; "), {
payload,
response,
})
}
return payload.data
}
export async function paginate(token, path) {
const items = []
for (let page = 1; page <= 10; page += 1) {
const url = new URL(`${API_BASE_URL}${path}`)
url.searchParams.set("per_page", "100")
url.searchParams.set("page", String(page))
const response = await fetch(url, {
headers: buildHeaders(token),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage("GET", `${path}?page=${page}`, response, payload), {
payload,
response,
})
}
if (!Array.isArray(payload)) {
throw new GitHubApiError(`Expected array payload from ${path}`, { payload })
}
items.push(...payload)
if (payload.length < 100)
break
}
return items
}
export async function getPullRequest(token, owner, repo, pullNumber) {
return apiRequest(token, `/repos/${owner}/${repo}/pulls/${pullNumber}`)
}
export async function listIssueLabels(token, owner, repo, issueNumber) {
const labels = await apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/labels?per_page=100`)
return labels.map(label => label.name)
}
export async function listIssueComments(token, owner, repo, issueNumber) {
return paginate(token, `/repos/${owner}/${repo}/issues/${issueNumber}/comments`)
}
export async function createIssueComment(token, owner, repo, issueNumber, body) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
body: { body },
method: "POST",
})
}
export async function updateIssueComment(token, owner, repo, commentId, body) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/comments/${commentId}`, {
body: { body },
method: "PATCH",
})
}
export async function addLabelsToIssue(token, owner, repo, issueNumber, labels) {
if (labels.length === 0)
return []
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
body: { labels },
method: "POST",
})
}
export async function removeLabelFromIssue(token, owner, repo, issueNumber, labelName) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(labelName)}`, {
method: "DELETE",
})
}
export async function closePullRequestIssue(token, owner, repo, issueNumber) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}`, {
body: { state: "closed" },
method: "PATCH",
})
}
export async function getCollaboratorPermission(token, owner, repo, username) {
try {
const response = await apiRequest(token, `/repos/${owner}/${repo}/collaborators/${encodeURIComponent(username)}/permission`)
return response.permission ?? null
}
catch (error) {
if (error instanceof GitHubApiError && error.details.response?.status === 404)
return null
throw error
}
}
function getPageNumberFromLinkHeader(linkHeader, relation) {
if (!linkHeader)
return null
for (const entry of linkHeader.split(",")) {
const match = entry.trim().match(LINK_HEADER_ENTRY_PATTERN)
if (!match || match[2] !== relation)
continue
const page = new URL(match[1]).searchParams.get("page")
const parsedPage = Number.parseInt(page ?? "", 10)
return Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : null
}
return null
}
export async function countAuthorCommitsInRepo(token, owner, repo, authorLogin) {
const url = new URL(`${API_BASE_URL}/repos/${owner}/${repo}/commits`)
url.searchParams.set("author", authorLogin)
url.searchParams.set("per_page", "1")
const response = await fetch(url, {
headers: buildHeaders(token),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage("GET", `${url.pathname}${url.search}`, response, payload), {
payload,
response,
})
}
if (!Array.isArray(payload))
throw new GitHubApiError(`Expected array payload from ${url.pathname}`, { payload })
const lastPage = getPageNumberFromLinkHeader(response.headers.get("link"), "last")
if (lastPage !== null)
return lastPage
return payload.length
}
export async function listRepositoryLabels(token, owner, repo) {
return paginate(token, `/repos/${owner}/${repo}/labels`)
}
export async function ensureRepositoryLabels(token, owner, repo, labelDefinitions) {
const existingLabels = new Set(
(await listRepositoryLabels(token, owner, repo)).map(label => label.name),
)
for (const [name, definition] of Object.entries(labelDefinitions)) {
if (existingLabels.has(name))
continue
try {
await apiRequest(token, `/repos/${owner}/${repo}/labels`, {
body: {
color: definition.color,
description: definition.description,
name,
},
method: "POST",
})
}
catch (createError) {
if (!(createError instanceof GitHubApiError) || createError.details.response?.status !== 422)
throw createError
}
}
}
function countReviewsOnOthersPullRequests(nodes, authorLogin) {
const normalizedAuthorLogin = authorLogin.toLowerCase()
let reviews = 0
for (const node of nodes ?? []) {
const pullRequestAuthor = node?.author?.login?.toLowerCase()
if (!pullRequestAuthor || pullRequestAuthor === normalizedAuthorLogin)
continue
reviews += 1
}
return reviews
}
function getReviewSearchPageInfo(searchResult) {
return {
endCursor: searchResult?.pageInfo?.endCursor ?? null,
hasNextPage: searchResult?.pageInfo?.hasNextPage === true,
}
}
function normalizeReviewSearchNodes(searchResult) {
return (searchResult?.nodes ?? []).filter(node => node?.__typename === "PullRequest")
}
export async function countReviewsOnOthersPullRequestsInRepo(token, owner, repo, authorLogin, pageSize) {
let reviews = 0
let cursor = null
while (true) {
const data = await graphqlRequest(token, `
query ReviewsOnOthersPullRequests(
$cursor: String
$pageSize: Int!
$reviewsQuery: String!
) {
search(query: $reviewsQuery, type: ISSUE, first: $pageSize, after: $cursor) {
nodes {
__typename
... on PullRequest {
author {
login
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
`, {
cursor,
pageSize,
reviewsQuery: `repo:${owner}/${repo} reviewed-by:${authorLogin} type:pr`,
})
const searchResult = data.search
reviews += countReviewsOnOthersPullRequests(
normalizeReviewSearchNodes(searchResult),
authorLogin,
)
const pageInfo = getReviewSearchPageInfo(searchResult)
if (!pageInfo.hasNextPage)
break
cursor = pageInfo.endCursor
if (!cursor)
break
}
return reviews
}
function coalesceAuthorUser(user, fallbackUser, authorLogin) {
return {
avatarUrl: user?.avatarUrl ?? fallbackUser?.avatar_url ?? null,
createdAt: user?.createdAt ?? fallbackUser?.created_at ?? null,
followers: user?.followers?.totalCount ?? fallbackUser?.followers ?? 0,
login: user?.login ?? fallbackUser?.login ?? authorLogin,
name: user?.name ?? fallbackUser?.name ?? null,
publicRepos: user?.repositories?.totalCount ?? fallbackUser?.public_repos ?? 0,
url: user?.url ?? fallbackUser?.html_url ?? `https://github.com/${authorLogin}`,
}
}
export function createPullRequestStateList({ closedPrs, mergedPrs, openPrs }) {
return [
...Array.from({ length: mergedPrs }).fill({ state: "merged" }),
...Array.from({ length: closedPrs }).fill({ state: "closed" }),
...Array.from({ length: openPrs }).fill({ state: "open" }),
]
}
export function createContributorMetrics({ author, permission, repoHistory }) {
const contributionCount = repoHistory.mergedPrs + repoHistory.reviews
return {
accountCreated: author.createdAt,
commitsInRepo: repoHistory.commitsInRepo,
contributionCount,
followers: author.followers,
isContributor: contributionCount > 0,
prsInRepo: createPullRequestStateList(repoHistory),
repoPermission: permission ?? null,
reviewsInRepo: repoHistory.reviews,
topRepoStars: repoHistory.topRepositories.map(repository => repository.stargazerCount),
}
}
function normalizeOwnedRepository(node, ownerLogin) {
if (!node?.nameWithOwner || !ownerLogin)
return null
const repositoryOwner = node.owner?.login?.toLowerCase()
if (repositoryOwner !== ownerLogin.toLowerCase())
return null
if (node.isFork === true)
return null
return {
nameWithOwner: node.nameWithOwner,
stargazerCount: Number(node.stargazerCount) || 0,
}
}
export function selectOwnedNonForkRepositories(nodes, ownerLogin) {
const topRepositories = []
for (const node of nodes ?? []) {
const repository = normalizeOwnedRepository(node, ownerLogin)
if (!repository)
continue
topRepositories.push(repository)
}
return topRepositories
}
export async function getAuthorMetrics(token, owner, repo, authorLogin) {
const query = `
query ContributorTrust(
$login: String!
$openPrsQuery: String!
$mergedPrsQuery: String!
$closedPrsQuery: String!
) {
user(login: $login) {
login
name
url
avatarUrl
createdAt
followers {
totalCount
}
repositories {
totalCount
}
ownedNonForkRepositories: repositories(
first: 20
ownerAffiliations: [OWNER]
isFork: false
orderBy: { field: STARGAZERS, direction: DESC }
) {
nodes {
isFork
nameWithOwner
owner {
login
}
stargazerCount
}
}
}
openPrs: search(query: $openPrsQuery, type: ISSUE, first: 1) {
issueCount
}
mergedPrs: search(query: $mergedPrsQuery, type: ISSUE, first: 1) {
issueCount
}
closedPrs: search(query: $closedPrsQuery, type: ISSUE, first: 1) {
issueCount
}
}
`
const variables = {
login: authorLogin,
openPrsQuery: `repo:${owner}/${repo} author:${authorLogin} type:pr is:open`,
mergedPrsQuery: `repo:${owner}/${repo} author:${authorLogin} type:pr is:merged`,
closedPrsQuery: `repo:${owner}/${repo} author:${authorLogin} type:pr is:closed is:unmerged`,
}
const data = await graphqlRequest(token, query, variables)
const fallbackUser = data.user ? null : await getUserFallback(token, authorLogin)
const author = coalesceAuthorUser(data.user, fallbackUser, authorLogin)
const [commitsInRepo, reviews] = await Promise.all([
countAuthorCommitsInRepo(token, owner, repo, authorLogin),
countReviewsOnOthersPullRequestsInRepo(
token,
owner,
repo,
authorLogin,
POLICY.reviewQueryPageSize,
),
])
const topRepositories = selectOwnedNonForkRepositories(data.user?.ownedNonForkRepositories?.nodes ?? [], authorLogin)
return {
author,
repoHistory: {
closedPrs: data.closedPrs?.issueCount ?? 0,
commitsInRepo,
mergedPrs: data.mergedPrs?.issueCount ?? 0,
openPrs: data.openPrs?.issueCount ?? 0,
reviews,
topRepositories,
},
}
}
async function getUserFallback(token, authorLogin) {
try {
return await apiRequest(token, `/users/${encodeURIComponent(authorLogin)}`)
}
catch (error) {
if (error instanceof GitHubApiError && error.details.response?.status === 404)
return null
throw error
}
}

View file

@ -1,207 +0,0 @@
import { describe, expect, it } from "vitest"
import {
countAuthorCommitsInRepo,
countReviewsOnOthersPullRequestsInRepo,
createContributorMetrics,
createPullRequestStateList,
selectOwnedNonForkRepositories,
} from "./github-api.js"
describe("selectOwnedNonForkRepositories", () => {
it("keeps only repos that the PR author owns and that are not forks", () => {
const result = selectOwnedNonForkRepositories([
{
isFork: true,
nameWithOwner: "kilidoc/read-frog",
owner: { login: "kilidoc" },
stargazerCount: 5040,
},
{
isFork: false,
nameWithOwner: "kilidoc/anki-langkit",
owner: { login: "kilidoc" },
stargazerCount: 42,
},
{
isFork: false,
nameWithOwner: "kilidoc/browser-tools",
owner: { login: "kilidoc" },
stargazerCount: 5,
},
{
isFork: false,
nameWithOwner: "mengxi-ream/read-frog",
owner: { login: "mengxi-ream" },
stargazerCount: 5041,
},
{
isFork: false,
nameWithOwner: "better-auth/better-auth",
owner: { login: "better-auth" },
stargazerCount: 27534,
},
], "kilidoc")
expect(result).toEqual([
{
nameWithOwner: "kilidoc/anki-langkit",
stargazerCount: 42,
},
{
nameWithOwner: "kilidoc/browser-tools",
stargazerCount: 5,
},
])
})
})
describe("createPullRequestStateList", () => {
it("reconstructs repo PR states from the aggregated counts", () => {
expect(createPullRequestStateList({
closedPrs: 1,
mergedPrs: 2,
openPrs: 3,
})).toEqual([
{ state: "merged" },
{ state: "merged" },
{ state: "closed" },
{ state: "open" },
{ state: "open" },
{ state: "open" },
])
})
})
describe("createContributorMetrics", () => {
it("keeps the contributor bonus inputs but does not invent commit counts", () => {
expect(createContributorMetrics({
author: {
createdAt: "2020-01-01T00:00:00Z",
followers: 12,
},
permission: "write",
repoHistory: {
closedPrs: 1,
commitsInRepo: 14,
mergedPrs: 2,
openPrs: 0,
reviews: 3,
topRepositories: [
{ nameWithOwner: "kilidoc/browser-tools", stargazerCount: 42 },
],
},
})).toEqual({
accountCreated: "2020-01-01T00:00:00Z",
commitsInRepo: 14,
contributionCount: 5,
followers: 12,
isContributor: true,
prsInRepo: [
{ state: "merged" },
{ state: "merged" },
{ state: "closed" },
],
repoPermission: "write",
reviewsInRepo: 3,
topRepoStars: [42],
})
})
})
describe("countAuthorCommitsInRepo", () => {
it("derives the commit count from the paginated repo commits API", async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify([{ sha: "head" }]), {
status: 200,
headers: {
"Content-Type": "application/json",
"Link": "<https://api.github.com/repositories/1/commits?author=Sufyr&per_page=1&page=2>; rel=\"next\", <https://api.github.com/repositories/1/commits?author=Sufyr&per_page=1&page=37>; rel=\"last\"",
},
})
try {
const count = await countAuthorCommitsInRepo(
"token",
"mengxi-ream",
"read-frog",
"Sufyr",
)
expect(count).toBe(37)
}
finally {
globalThis.fetch = originalFetch
}
})
it("falls back to payload length when the response fits on one page", async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify([{ sha: "only" }]), {
status: 200,
headers: { "Content-Type": "application/json" },
})
try {
const count = await countAuthorCommitsInRepo(
"token",
"mengxi-ream",
"read-frog",
"Sufyr",
)
expect(count).toBe(1)
}
finally {
globalThis.fetch = originalFetch
}
})
})
describe("countReviewsOnOthersPullRequestsInRepo", () => {
it("counts only reviews left on pull requests authored by someone else", async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify({
data: {
search: {
nodes: [
{
__typename: "PullRequest",
author: { login: "someone-else" },
},
{
__typename: "PullRequest",
author: { login: "Sufyr" },
},
{
__typename: "PullRequest",
author: { login: "another-user" },
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
},
},
}), {
status: 200,
headers: { "Content-Type": "application/json" },
})
try {
const count = await countReviewsOnOthersPullRequestsInRepo(
"token",
"mengxi-ream",
"read-frog",
"Sufyr",
50,
)
expect(count).toBe(2)
}
finally {
globalThis.fetch = originalFetch
}
})
})

View file

@ -1,10 +0,0 @@
import { COMMENT_MARKER_HTML, MANAGED_COMMENT_AUTHOR } from "./config.js"
export function isManagedTrustComment(comment) {
return comment?.user?.login === MANAGED_COMMENT_AUTHOR
&& comment.body?.includes(COMMENT_MARKER_HTML)
}
export function findManagedTrustComment(comments) {
return comments.find(isManagedTrustComment)
}

View file

@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest"
import { findManagedTrustComment, isManagedTrustComment } from "./managed-comment.js"
describe("managed trust comments", () => {
it("only matches marker comments authored by github-actions[bot]", () => {
const foreignComment = {
body: "<!-- contributor-trust-score:v1 -->\nspoofed marker",
id: 1,
user: { login: "malicious-user" },
}
const managedComment = {
body: "<!-- contributor-trust-score:v1 -->\nmanaged marker",
id: 2,
user: { login: "github-actions[bot]" },
}
expect(isManagedTrustComment(foreignComment)).toBe(false)
expect(isManagedTrustComment(managedComment)).toBe(true)
expect(findManagedTrustComment([foreignComment, managedComment])).toEqual(managedComment)
})
})

View file

@ -1,71 +0,0 @@
import { bucketToLabel, MANAGED_TRUST_LABELS, POLICY } from "./config.js"
function uniqueSorted(values) {
return [...new Set(values)].sort((left, right) => left.localeCompare(right))
}
function getPullRequestChangedLines(pullRequest) {
const additions = Number(pullRequest?.additions) || 0
const deletions = Number(pullRequest?.deletions) || 0
return additions + deletions
}
export function planTrustActions({ currentLabels = [], pullRequest = null, score }) {
const labelSet = new Set(currentLabels)
if (labelSet.has(POLICY.overrideLabel)) {
return {
skipAutomation: true,
skipReason: `Override label \`${POLICY.overrideLabel}\` is present.`,
targetTrustLabel: null,
labelsToAdd: [],
labelsToRemove: labelSet.has(POLICY.needsMaintainerReviewLabel)
? [POLICY.needsMaintainerReviewLabel]
: [],
needsMaintainerReview: false,
shouldClosePr: false,
closeReason: null,
}
}
const targetTrustLabel = bucketToLabel(score.bucket)
const needsMaintainerReview = score.total < POLICY.lowScoreThreshold
const changedLines = getPullRequestChangedLines(pullRequest)
const labelsToAdd = []
const labelsToRemove = []
if (!labelSet.has(targetTrustLabel))
labelsToAdd.push(targetTrustLabel)
for (const label of MANAGED_TRUST_LABELS) {
if (label !== targetTrustLabel && labelSet.has(label))
labelsToRemove.push(label)
}
if (needsMaintainerReview) {
if (!labelSet.has(POLICY.needsMaintainerReviewLabel))
labelsToAdd.push(POLICY.needsMaintainerReviewLabel)
}
else if (labelSet.has(POLICY.needsMaintainerReviewLabel)) {
labelsToRemove.push(POLICY.needsMaintainerReviewLabel)
}
const shouldClosePr = POLICY.autoCloseBelowScore !== null
&& score.total < POLICY.autoCloseBelowScore
&& changedLines > POLICY.autoCloseAboveChangedLines
return {
skipAutomation: false,
skipReason: null,
targetTrustLabel,
labelsToAdd: uniqueSorted(labelsToAdd),
labelsToRemove: uniqueSorted(labelsToRemove),
needsMaintainerReview,
changedLines,
shouldClosePr,
closeReason: shouldClosePr
? `Score ${score.total} is below ${POLICY.autoCloseBelowScore} and the PR changes ${changedLines} lines, exceeding ${POLICY.autoCloseAboveChangedLines}.`
: null,
}
}

View file

@ -1,130 +0,0 @@
import { describe, expect, it } from "vitest"
import { POLICY } from "./config.js"
import { planTrustActions } from "./plan-actions.js"
describe("planTrustActions", () => {
it("assigns the low-trust labels for new contributors", () => {
const plan = planTrustActions({
currentLabels: [],
pullRequest: { additions: 15, deletions: 4 },
score: {
bucket: "new",
exemptReason: null,
total: 18,
},
})
expect(plan).toMatchObject({
changedLines: 19,
labelsToAdd: ["contrib-trust:new", POLICY.needsMaintainerReviewLabel].sort(),
labelsToRemove: [],
needsMaintainerReview: true,
shouldClosePr: false,
skipAutomation: false,
targetTrustLabel: "contrib-trust:new",
})
})
it("cleans up stale trust labels when the score improves", () => {
const plan = planTrustActions({
currentLabels: ["contrib-trust:new", POLICY.needsMaintainerReviewLabel],
pullRequest: { additions: 40, deletions: 10 },
score: {
bucket: "trusted",
exemptReason: null,
total: 74,
},
})
expect(plan).toMatchObject({
changedLines: 50,
labelsToAdd: ["contrib-trust:trusted"],
labelsToRemove: [POLICY.needsMaintainerReviewLabel, "contrib-trust:new"].sort(),
needsMaintainerReview: false,
skipAutomation: false,
targetTrustLabel: "contrib-trust:trusted",
})
})
it("cleans up the legacy admin trust label when recomputing a score", () => {
const plan = planTrustActions({
currentLabels: ["contrib-trust:new", POLICY.adminLabel],
pullRequest: { additions: 25, deletions: 5 },
score: {
bucket: "highly-trusted",
exemptReason: null,
total: 82,
},
})
expect(plan).toMatchObject({
changedLines: 30,
labelsToAdd: ["contrib-trust:highly-trusted"],
labelsToRemove: [POLICY.adminLabel, "contrib-trust:new"].sort(),
needsMaintainerReview: false,
skipAutomation: false,
targetTrustLabel: "contrib-trust:highly-trusted",
})
})
it("short-circuits when the override label is present", () => {
const plan = planTrustActions({
currentLabels: [POLICY.overrideLabel, POLICY.needsMaintainerReviewLabel, "contrib-trust:new"],
pullRequest: { additions: 900, deletions: 400 },
score: {
bucket: "new",
exemptReason: null,
total: 10,
},
})
expect(plan).toMatchObject({
labelsToAdd: [],
labelsToRemove: [POLICY.needsMaintainerReviewLabel],
needsMaintainerReview: false,
shouldClosePr: false,
skipAutomation: true,
targetTrustLabel: null,
})
})
it("auto-closes only when both the score and changed-line thresholds are exceeded", () => {
const plan = planTrustActions({
currentLabels: [],
pullRequest: { additions: 820, deletions: 245 },
score: {
bucket: "new",
exemptReason: null,
total: 19,
},
})
expect(plan).toMatchObject({
changedLines: 1065,
shouldClosePr: true,
skipAutomation: false,
})
expect(plan.closeReason).toContain("Score 19 is below 20")
expect(plan.closeReason).toContain("1065 lines")
expect(plan.closeReason).toContain("exceeding 1000")
})
it("does not auto-close a low-score PR when it is still under the line threshold", () => {
const plan = planTrustActions({
currentLabels: [],
pullRequest: { additions: 600, deletions: 300 },
score: {
bucket: "new",
exemptReason: null,
total: 10,
},
})
expect(plan).toMatchObject({
changedLines: 900,
shouldClosePr: false,
})
expect(plan.closeReason).toBeNull()
})
})

View file

@ -1,289 +0,0 @@
import { appendFile, readFile } from "node:fs/promises"
import { resolve } from "node:path"
import process from "node:process"
import { fileURLToPath } from "node:url"
import { getBotAuthorSkipReason } from "./bot-author.js"
import { buildTrustComment } from "./comment-template.js"
import { LABEL_DEFINITIONS, POLICY } from "./config.js"
import {
addLabelsToIssue,
closePullRequestIssue,
createContributorMetrics,
createIssueComment,
ensureRepositoryLabels,
getAuthorMetrics,
getCollaboratorPermission,
getPullRequest,
listIssueComments,
listIssueLabels,
removeLabelFromIssue,
updateIssueComment,
} from "./github-api.js"
import { findManagedTrustComment } from "./managed-comment.js"
import { planTrustActions } from "./plan-actions.js"
import { computeContributorScore } from "./score-author.js"
function getRequiredEnv(name) {
const value = process.env[name]
if (!value)
throw new Error(`Missing required environment variable: ${name}`)
return value
}
function getRepository() {
const repository = getRequiredEnv("GITHUB_REPOSITORY")
const [owner, repo] = repository.split("/")
if (!owner || !repo)
throw new Error(`Invalid GITHUB_REPOSITORY value: ${repository}`)
return { owner, repo }
}
async function getEventPayload() {
const eventPath = getRequiredEnv("GITHUB_EVENT_PATH")
return JSON.parse(await readFile(eventPath, "utf8"))
}
function resolvePullNumber(eventName, payload) {
const manualInput = process.env.TRUST_PR_NUMBER?.trim()
if (manualInput)
return Number.parseInt(manualInput, 10)
if (eventName === "pull_request_target")
return payload.pull_request?.number
return null
}
async function writeJobSummary(lines) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (!summaryPath)
return
await appendFile(summaryPath, `${lines.join("\n")}\n`)
}
function buildScoreBreakdownSummary(score) {
const { communityStanding, ossInfluence, prTrackRecord, repoFamiliarity } = score.breakdown
return [
"",
"### Score details",
`- Repo familiarity: ${score.repoFamiliarity}/35`,
` - Commits in repo: ${repoFamiliarity.commitsInRepo.count} -> ${repoFamiliarity.commitsInRepo.points}/10`,
` - Merged PRs: ${repoFamiliarity.mergedPrs.count} -> ${repoFamiliarity.mergedPrs.points}/12`,
` - Reviews in repo: ${repoFamiliarity.reviewsInRepo.count} -> ${repoFamiliarity.reviewsInRepo.points}/8`,
` - Contributor bonus: ${repoFamiliarity.contributorBonus.eligible ? "yes" : "no"} -> ${repoFamiliarity.contributorBonus.points}/5`,
`- Community standing: ${score.communityStanding}/25`,
` - Account age: ${communityStanding.accountAge.months} months -> ${communityStanding.accountAge.points}/5`,
` - Followers: ${communityStanding.followers.count} -> ${communityStanding.followers.points}/10`,
` - Repo permission (${communityStanding.repoPermission.permission}) -> ${communityStanding.repoPermission.points}/10`,
`- OSS influence: ${score.ossInfluence}/20`,
` - Max owned repo stars: ${ossInfluence.maxOwnedRepoStars.count} -> ${ossInfluence.maxOwnedRepoStars.points}/15`,
` - Total owned repo stars: ${ossInfluence.totalOwnedRepoStars.count} -> ${ossInfluence.totalOwnedRepoStars.points}/5`,
`- PR track record: ${score.prTrackRecord}/20`,
` - Merged PRs: ${prTrackRecord.mergedPrs}`,
` - Resolved PRs: ${prTrackRecord.resolvedPrs}`,
` - Smoothed merge rate: ${prTrackRecord.smoothedRate}`,
` - Confidence: ${prTrackRecord.confidence}`,
]
}
function logScoreBreakdown(score) {
console.log("Contributor trust score breakdown:")
console.log(JSON.stringify({
bucket: score.bucket,
breakdown: score.breakdown,
total: score.total,
}, null, 2))
}
async function syncLabels({ currentLabels, issueNumber, labelsToAdd, labelsToRemove, owner, repo, token }) {
for (const label of labelsToRemove) {
if (!currentLabels.includes(label))
continue
await removeLabelFromIssue(token, owner, repo, issueNumber, label)
console.log(`Removed label: ${label}`)
}
const missingLabels = labelsToAdd.filter(label => !currentLabels.includes(label))
if (missingLabels.length > 0) {
await addLabelsToIssue(token, owner, repo, issueNumber, missingLabels)
console.log(`Added labels: ${missingLabels.join(", ")}`)
}
}
async function upsertComment({ body, issueNumber, owner, repo, token }) {
const comments = await listIssueComments(token, owner, repo, issueNumber)
const existingComment = findManagedTrustComment(comments)
if (existingComment?.body === body) {
console.log("Trust comment is already up to date.")
return { action: "unchanged", commentId: existingComment.id }
}
if (existingComment) {
await updateIssueComment(token, owner, repo, existingComment.id, body)
console.log(`Updated trust comment ${existingComment.id}.`)
return { action: "updated", commentId: existingComment.id }
}
const createdComment = await createIssueComment(token, owner, repo, issueNumber, body)
console.log(`Created trust comment ${createdComment.id}.`)
return { action: "created", commentId: createdComment.id }
}
export async function main() {
const token = getRequiredEnv("GITHUB_TOKEN")
const eventName = getRequiredEnv("GITHUB_EVENT_NAME")
const payload = await getEventPayload()
const pullNumber = resolvePullNumber(eventName, payload)
if (!Number.isInteger(pullNumber) || pullNumber <= 0)
throw new Error(`Unable to resolve a valid pull request number for event ${eventName}.`)
const { owner, repo } = getRepository()
const pullRequest = await getPullRequest(token, owner, repo, pullNumber)
const summaryLines = [
"## Contributor trust automation",
"",
`- Event: \`${eventName}\``,
`- PR: #${pullRequest.number}`,
`- Title: ${pullRequest.title}`,
`- State: ${pullRequest.state}${pullRequest.draft ? " (draft)" : ""}`,
`- Author: @${pullRequest.user.login}`,
]
const botSkipReason = getBotAuthorSkipReason(pullRequest.user)
if (botSkipReason) {
summaryLines.push(`- Result: skipped ${botSkipReason}`)
await writeJobSummary(summaryLines)
return
}
if (eventName === "pull_request_target" && pullRequest.draft) {
summaryLines.push("- Result: skipped automatic trust checks for a draft PR")
await writeJobSummary(summaryLines)
return
}
await ensureRepositoryLabels(token, owner, repo, LABEL_DEFINITIONS)
const currentLabels = await listIssueLabels(token, owner, repo, pullRequest.number)
const overridePlan = planTrustActions({
currentLabels,
pullRequest,
score: {
bucket: "new",
exemptReason: null,
total: 0,
},
})
if (overridePlan.skipAutomation) {
await syncLabels({
currentLabels,
issueNumber: pullRequest.number,
labelsToAdd: [],
labelsToRemove: overridePlan.labelsToRemove,
owner,
repo,
token,
})
summaryLines.push(`- Result: skipped due to \`${POLICY.overrideLabel}\``)
if (overridePlan.labelsToRemove.length > 0)
summaryLines.push(`- Cleanup: removed ${overridePlan.labelsToRemove.map(label => `\`${label}\``).join(", ")}`)
await writeJobSummary(summaryLines)
return
}
const permission = await getCollaboratorPermission(token, owner, repo, pullRequest.user.login)
const authorMetrics = await getAuthorMetrics(token, owner, repo, pullRequest.user.login)
const scoreInput = createContributorMetrics({
author: authorMetrics.author,
permission,
repoHistory: authorMetrics.repoHistory,
})
const score = computeContributorScore(scoreInput)
const plan = planTrustActions({ currentLabels, pullRequest, score })
const comment = buildTrustComment({
author: {
login: authorMetrics.author.login,
name: authorMetrics.author.name,
type: pullRequest.user.type,
url: authorMetrics.author.url,
},
metrics: {
accountCreated: authorMetrics.author.createdAt,
closedPrs: authorMetrics.repoHistory.closedPrs,
commitsInRepo: authorMetrics.repoHistory.commitsInRepo,
followers: authorMetrics.author.followers,
mergedPrs: authorMetrics.repoHistory.mergedPrs,
openPrs: authorMetrics.repoHistory.openPrs,
repoPermission: permission ?? "none",
reviews: authorMetrics.repoHistory.reviews,
topRepositories: authorMetrics.repoHistory.topRepositories,
},
owner,
plan,
pullRequest,
repo,
score,
})
await syncLabels({
currentLabels,
issueNumber: pullRequest.number,
labelsToAdd: plan.labelsToAdd,
labelsToRemove: plan.labelsToRemove,
owner,
repo,
token,
})
const commentResult = await upsertComment({
body: comment.body,
issueNumber: pullRequest.number,
owner,
repo,
token,
})
if (plan.shouldClosePr) {
await closePullRequestIssue(token, owner, repo, pullRequest.number)
console.log(`Closed PR #${pullRequest.number}: ${plan.closeReason}`)
}
logScoreBreakdown(score)
summaryLines.push(`- Trust score: **${score.total}/100**`)
summaryLines.push(`- PR changed lines: ${plan.changedLines}`)
summaryLines.push(`- Bucket: \`${score.bucket}\``)
summaryLines.push(`- Target label: \`${plan.targetTrustLabel}\``)
summaryLines.push(`- Maintainer review: ${plan.needsMaintainerReview ? "required" : "not required"}`)
if (plan.closeReason)
summaryLines.push(`- Auto-close: ${plan.closeReason}`)
summaryLines.push(`- Comment: ${commentResult.action}`)
summaryLines.push(`- Fingerprint: \`${comment.fingerprint}\``)
summaryLines.push(...buildScoreBreakdownSummary(score))
await writeJobSummary(summaryLines)
}
const isDirectExecution = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isDirectExecution) {
main().catch(async (error) => {
console.error(error)
await writeJobSummary([
"## Contributor trust automation",
"",
`- Result: failed`,
`- Error: ${error instanceof Error ? error.message : String(error)}`,
])
process.exitCode = 1
})
}

View file

@ -1,208 +0,0 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import process from "node:process"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
buildTrustComment: vi.fn(),
computeContributorScore: vi.fn(),
createContributorMetrics: vi.fn(),
createIssueComment: vi.fn(),
addLabelsToIssue: vi.fn(),
closePullRequestIssue: vi.fn(),
ensureRepositoryLabels: vi.fn(),
getAuthorMetrics: vi.fn(),
getCollaboratorPermission: vi.fn(),
getPullRequest: vi.fn(),
listIssueComments: vi.fn(),
listIssueLabels: vi.fn(),
removeLabelFromIssue: vi.fn(),
updateIssueComment: vi.fn(),
findManagedTrustComment: vi.fn(),
}))
vi.mock("./comment-template.js", () => ({
buildTrustComment: mocks.buildTrustComment,
}))
vi.mock("./github-api.js", () => ({
addLabelsToIssue: mocks.addLabelsToIssue,
closePullRequestIssue: mocks.closePullRequestIssue,
createContributorMetrics: mocks.createContributorMetrics,
createIssueComment: mocks.createIssueComment,
ensureRepositoryLabels: mocks.ensureRepositoryLabels,
getAuthorMetrics: mocks.getAuthorMetrics,
getCollaboratorPermission: mocks.getCollaboratorPermission,
getPullRequest: mocks.getPullRequest,
listIssueComments: mocks.listIssueComments,
listIssueLabels: mocks.listIssueLabels,
removeLabelFromIssue: mocks.removeLabelFromIssue,
updateIssueComment: mocks.updateIssueComment,
}))
vi.mock("./managed-comment.js", () => ({
findManagedTrustComment: mocks.findManagedTrustComment,
}))
vi.mock("./score-author.js", () => ({
computeContributorScore: mocks.computeContributorScore,
}))
const ENV_KEYS = [
"GITHUB_EVENT_NAME",
"GITHUB_EVENT_PATH",
"GITHUB_REPOSITORY",
"GITHUB_STEP_SUMMARY",
"GITHUB_TOKEN",
"TRUST_PR_NUMBER",
]
const originalEnv = { ...process.env }
async function loadMain() {
vi.resetModules()
return (await import("./run.js")).main
}
describe("main", () => {
beforeEach(async () => {
vi.clearAllMocks()
const tempDir = await mkdtemp(join(tmpdir(), "contributor-trust-run-"))
const eventPath = join(tempDir, "event.json")
const summaryPath = join(tempDir, "summary.md")
await writeFile(eventPath, JSON.stringify({}), "utf8")
await writeFile(summaryPath, "", "utf8")
process.env.GITHUB_EVENT_NAME = "workflow_dispatch"
process.env.GITHUB_EVENT_PATH = eventPath
process.env.GITHUB_REPOSITORY = "read-frog/read-frog"
process.env.GITHUB_STEP_SUMMARY = summaryPath
process.env.GITHUB_TOKEN = "test-token"
process.env.TRUST_PR_NUMBER = "42"
})
afterEach(() => {
for (const key of ENV_KEYS)
delete process.env[key]
Object.assign(process.env, originalEnv)
})
it("skips bot-authored pull requests without mutating trust state", async () => {
mocks.getPullRequest.mockResolvedValue({
draft: false,
number: 42,
state: "open",
title: "chore(deps): bump dependencies",
user: {
login: "dependabot[bot]",
type: "Bot",
},
})
const main = await loadMain()
await main()
expect(mocks.ensureRepositoryLabels).not.toHaveBeenCalled()
expect(mocks.listIssueLabels).not.toHaveBeenCalled()
expect(mocks.getCollaboratorPermission).not.toHaveBeenCalled()
expect(mocks.getAuthorMetrics).not.toHaveBeenCalled()
const summary = await readFile(process.env.GITHUB_STEP_SUMMARY, "utf8")
expect(summary).toContain("- Result: skipped bot-authored PR by @dependabot[bot]")
})
it("continues to process human-authored pull requests", async () => {
mocks.getPullRequest.mockResolvedValue({
additions: 20,
deletions: 5,
draft: false,
number: 42,
state: "open",
title: "fix: keep contributor trust scoring for humans",
user: {
login: "mengxi-ream",
type: "User",
},
})
mocks.ensureRepositoryLabels.mockResolvedValue(undefined)
mocks.listIssueLabels.mockResolvedValue([])
mocks.getCollaboratorPermission.mockResolvedValue("write")
mocks.getAuthorMetrics.mockResolvedValue({
author: {
createdAt: "2020-01-01T00:00:00Z",
followers: 12,
login: "mengxi-ream",
name: "Mengxi Ream",
url: "https://github.com/mengxi-ream",
},
repoHistory: {
closedPrs: 1,
commitsInRepo: 14,
mergedPrs: 2,
openPrs: 0,
reviews: 3,
topRepositories: [],
},
})
mocks.createContributorMetrics.mockReturnValue({ source: "metrics" })
mocks.computeContributorScore.mockReturnValue({
breakdown: {
communityStanding: {
accountAge: { months: 24, points: 5 },
followers: { count: 12, points: 10 },
repoPermission: { permission: "write", points: 10 },
},
ossInfluence: {
maxOwnedRepoStars: { count: 0, points: 0 },
totalOwnedRepoStars: { count: 0, points: 0 },
},
prTrackRecord: {
confidence: "medium",
mergedPrs: 2,
resolvedPrs: 3,
smoothedRate: 0.75,
},
repoFamiliarity: {
commitsInRepo: { count: 14, points: 10 },
contributorBonus: { eligible: true, points: 5 },
mergedPrs: { count: 2, points: 12 },
reviewsInRepo: { count: 3, points: 8 },
},
},
bucket: "trusted",
communityStanding: 25,
ossInfluence: 0,
prTrackRecord: 18,
repoFamiliarity: 35,
total: 78,
})
mocks.buildTrustComment.mockReturnValue({
body: "managed trust comment",
fingerprint: "fingerprint-123",
})
mocks.listIssueComments.mockResolvedValue([])
mocks.createIssueComment.mockResolvedValue({ id: 99 })
mocks.addLabelsToIssue.mockResolvedValue([])
const main = await loadMain()
await main()
expect(mocks.ensureRepositoryLabels).toHaveBeenCalledTimes(1)
expect(mocks.getCollaboratorPermission).toHaveBeenCalledWith(
"test-token",
"read-frog",
"read-frog",
"mengxi-ream",
)
expect(mocks.getAuthorMetrics).toHaveBeenCalledTimes(1)
expect(mocks.createIssueComment).toHaveBeenCalledTimes(1)
const summary = await readFile(process.env.GITHUB_STEP_SUMMARY, "utf8")
expect(summary).toContain("- Trust score: **78/100**")
})
})

View file

@ -1,226 +0,0 @@
import { POLICY, TRUST_BUCKETS } from "./config.js"
function toNumber(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
function roundNumber(value, digits = 2) {
if (!Number.isFinite(value))
return 0
return Number(value.toFixed(digits))
}
function accountAgeMonths(createdAt) {
if (!createdAt)
return 0
const timestamp = new Date(createdAt).getTime()
if (Number.isNaN(timestamp))
return 0
return (Date.now() - timestamp) / 2.628e9
}
function scoreRepoFamiliarity(input) {
const commitsInRepo = toNumber(input.commitsInRepo)
const commitPoints = commitsInRepo === 0 ? 0 : commitsInRepo <= 5 ? 3 : commitsInRepo <= 20 ? 7 : 10
const mergedPrs = input.prsInRepo.filter(pr => pr.state === "merged").length
const mergedPrPoints = mergedPrs === 0 ? 0 : mergedPrs === 1 ? 3 : mergedPrs <= 5 ? 6 : mergedPrs <= 15 ? 9 : 12
const reviewsInRepo = toNumber(input.reviewsInRepo)
const reviewPoints = reviewsInRepo === 0 ? 0 : reviewsInRepo <= 3 ? 3 : reviewsInRepo <= 10 ? 5 : 8
const contributorBonus = input.isContributor ? 5 : 0
const score = commitPoints + mergedPrPoints + reviewPoints + contributorBonus
return {
score: Math.min(score, 35),
details: {
achievableMax: 35,
commitsInRepo: {
count: commitsInRepo,
maxPoints: 10,
points: commitPoints,
},
configuredMax: 35,
contributorBonus: {
eligible: Boolean(input.isContributor),
maxPoints: 5,
points: contributorBonus,
},
mergedPrs: {
count: mergedPrs,
maxPoints: 12,
points: mergedPrPoints,
},
reviewsInRepo: {
count: reviewsInRepo,
maxPoints: 8,
points: reviewPoints,
},
},
}
}
function scoreCommunityStanding(input) {
const accountMonths = accountAgeMonths(input.accountCreated)
const accountAgePoints = accountMonths < 3 ? 0 : accountMonths < 12 ? 2 : accountMonths < 36 ? 3 : accountMonths < 84 ? 4 : 5
const followers = toNumber(input.followers)
const followerPoints = followers < 10 ? 1 : followers < 50 ? 3 : followers < 200 ? 5 : followers < 1000 ? 7 : 10
const permissionBonus = POLICY.repoFamiliarityBonusPermissions.includes(input.repoPermission ?? "") ? 10 : 0
const score = accountAgePoints + followerPoints + permissionBonus
return {
score: Math.min(score, 25),
details: {
achievableMax: 25,
accountAge: {
maxPoints: 5,
months: roundNumber(accountMonths, 1),
points: accountAgePoints,
},
configuredMax: 25,
followers: {
count: followers,
maxPoints: 10,
points: followerPoints,
},
repoPermission: {
bonusEligible: POLICY.repoFamiliarityBonusPermissions.includes(input.repoPermission ?? ""),
maxPoints: 10,
permission: input.repoPermission ?? "none",
points: permissionBonus,
},
},
}
}
function scoreOSSInfluence(input) {
const topRepoStars = input.topRepoStars.map(toNumber)
const maxStars = topRepoStars.length > 0 ? Math.max(...topRepoStars) : 0
const totalStars = topRepoStars.reduce((sum, stars) => sum + stars, 0)
const maxRepoPoints = maxStars === 0 ? 0 : maxStars <= 50 ? 3 : maxStars <= 500 ? 6 : maxStars <= 5000 ? 12 : 15
const totalRepoPoints = totalStars < 50 ? 0 : totalStars < 500 ? 2 : 5
const score = maxRepoPoints + totalRepoPoints
return {
score: Math.min(score, 20),
details: {
achievableMax: 20,
configuredMax: 20,
maxOwnedRepoStars: {
count: maxStars,
maxPoints: 15,
points: maxRepoPoints,
},
totalOwnedRepoStars: {
count: totalStars,
maxPoints: 5,
points: totalRepoPoints,
},
},
}
}
function scorePRTrackRecord(input) {
if (input.prsInRepo.length === 0) {
return {
score: 5,
details: {
achievableMax: 20,
configuredMax: 20,
confidence: 0,
mergedPrs: 0,
points: 5,
reason: "no-pr-history",
resolvedPrs: 0,
smoothedRate: 0,
},
}
}
const mergedPrs = input.prsInRepo.filter(pr => pr.state === "merged").length
const resolvedPrs = input.prsInRepo.filter(pr => pr.state === "merged" || pr.state === "closed").length
if (resolvedPrs === 0) {
return {
score: 5,
details: {
achievableMax: 20,
configuredMax: 20,
confidence: 0,
mergedPrs,
points: 5,
reason: "no-resolved-prs",
resolvedPrs,
smoothedRate: 0,
},
}
}
const smoothedRate = (mergedPrs + 1) / (resolvedPrs + 2)
const confidence = Math.min(1, Math.log2(resolvedPrs + 1) / Math.log2(11))
return {
score: Math.round(20 * smoothedRate * confidence),
details: {
achievableMax: 20,
configuredMax: 20,
confidence: roundNumber(confidence, 4),
mergedPrs,
points: Math.round(20 * smoothedRate * confidence),
reason: "resolved-pr-history",
resolvedPrs,
smoothedRate: roundNumber(smoothedRate, 4),
},
}
}
export function getTrustBucket(total) {
if (total >= 80)
return TRUST_BUCKETS.HIGHLY_TRUSTED
if (total >= 60)
return TRUST_BUCKETS.TRUSTED
if (total >= 30)
return TRUST_BUCKETS.MODERATE
return TRUST_BUCKETS.NEW
}
export function computeContributorScore(input) {
const repoFamiliarity = scoreRepoFamiliarity(input)
const communityStanding = scoreCommunityStanding(input)
const ossInfluence = scoreOSSInfluence(input)
const prTrackRecord = scorePRTrackRecord(input)
const total = repoFamiliarity.score + communityStanding.score + ossInfluence.score + prTrackRecord.score
const achievableMax = [
repoFamiliarity.details.achievableMax,
communityStanding.details.achievableMax,
ossInfluence.details.achievableMax,
prTrackRecord.details.achievableMax,
].reduce((sum, value) => sum + value, 0)
return {
breakdown: {
communityStanding: communityStanding.details,
ossInfluence: ossInfluence.details,
prTrackRecord: prTrackRecord.details,
repoFamiliarity: repoFamiliarity.details,
total: {
achievableMax,
configuredMax: 100,
},
},
total,
repoFamiliarity: repoFamiliarity.score,
communityStanding: communityStanding.score,
ossInfluence: ossInfluence.score,
prTrackRecord: prTrackRecord.score,
bucket: getTrustBucket(total),
lowScoreThreshold: POLICY.lowScoreThreshold,
}
}

View file

@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest"
import { TRUST_BUCKETS } from "./config.js"
import { computeContributorScore, getTrustBucket } from "./score-author.js"
function createBaseInput() {
return {
accountCreated: "2020-01-01T00:00:00Z",
commitsInRepo: 0,
contributionCount: 0,
followers: 0,
isContributor: false,
prsInRepo: [],
repoPermission: null,
reviewsInRepo: 0,
topRepoStars: [],
}
}
describe("getTrustBucket", () => {
it("maps score boundaries to the expected bucket", () => {
expect(getTrustBucket(80)).toBe(TRUST_BUCKETS.HIGHLY_TRUSTED)
expect(getTrustBucket(79)).toBe(TRUST_BUCKETS.TRUSTED)
expect(getTrustBucket(60)).toBe(TRUST_BUCKETS.TRUSTED)
expect(getTrustBucket(59)).toBe(TRUST_BUCKETS.MODERATE)
expect(getTrustBucket(30)).toBe(TRUST_BUCKETS.MODERATE)
expect(getTrustBucket(29)).toBe(TRUST_BUCKETS.NEW)
})
})
describe("computeContributorScore", () => {
it("does not grant any direct trust exemption to repo admins", () => {
const score = computeContributorScore({
...createBaseInput(),
repoPermission: "admin",
})
expect(score).toMatchObject({
bucket: TRUST_BUCKETS.NEW,
communityStanding: 15,
prTrackRecord: 5,
repoFamiliarity: 0,
total: 20,
})
})
it("keeps first-time contributors in the new bucket with the neutral PR history score", () => {
const score = computeContributorScore(createBaseInput())
expect(score.total).toBe(10)
expect(score.prTrackRecord).toBe(5)
expect(score.bucket).toBe(TRUST_BUCKETS.NEW)
})
it("does not over-reward a single merged PR", () => {
const score = computeContributorScore({
...createBaseInput(),
commitsInRepo: 1,
contributionCount: 1,
isContributor: true,
prsInRepo: [{ state: "merged" }],
})
expect(score.prTrackRecord).toBe(4)
expect(score.repoFamiliarity).toBe(11)
})
it("accumulates the better-hub style dimensions for experienced contributors", () => {
const score = computeContributorScore({
...createBaseInput(),
commitsInRepo: 24,
contributionCount: 18,
followers: 230,
isContributor: true,
prsInRepo: [
...Array.from({ length: 9 }).fill({ state: "merged" }),
{ state: "closed" },
],
reviewsInRepo: 12,
topRepoStars: [520, 40, 12],
})
expect(score).toMatchObject({
bucket: TRUST_BUCKETS.TRUSTED,
communityStanding: 11,
ossInfluence: 17,
prTrackRecord: 17,
repoFamiliarity: 32,
total: 77,
})
})
it("approaches a high track record score only after enough resolved PRs", () => {
const score = computeContributorScore({
...createBaseInput(),
commitsInRepo: 10,
contributionCount: 10,
followers: 12,
isContributor: true,
prsInRepo: Array.from({ length: 10 }).fill({ state: "merged" }),
})
expect(score.prTrackRecord).toBe(18)
})
it("adds the full community standing bonus for admin, maintain, and write access", () => {
for (const repoPermission of ["admin", "maintain", "write"]) {
const score = computeContributorScore({
...createBaseInput(),
repoPermission,
})
expect(score.communityStanding).toBe(15)
}
})
})

View file

@ -1,102 +0,0 @@
name: PR - Contributor Trust
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- ready_for_review
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to re-score
required: true
type: number
concurrency:
group: contributor-trust-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
cancel-in-progress: true
jobs:
preflight:
name: Preflight
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
author_login: ${{ steps.inspect.outputs.author_login }}
pr_number: ${{ steps.inspect.outputs.pr_number }}
should_skip: ${{ steps.inspect.outputs.should_skip }}
skip_reason: ${{ steps.inspect.outputs.skip_reason }}
steps:
- name: Resolve PR metadata
id: inspect
uses: actions/github-script@v8
with:
script: |
// workflow_dispatch inputs live on the event payload, not as github-script action inputs.
const prNumber = context.eventName === "pull_request_target"
? context.payload.pull_request?.number
: Number(context.payload.inputs?.pr_number ?? "");
if (!Number.isInteger(prNumber) || prNumber <= 0)
throw new Error(`Unable to resolve a valid pull request number for event ${context.eventName}.`);
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const authorLogin = pullRequest.user?.login ?? "";
const authorType = pullRequest.user?.type ?? "";
const shouldSkip = authorType === "Bot" || authorLogin.toLowerCase().endsWith("[bot]");
core.setOutput("author_login", authorLogin);
core.setOutput("pr_number", String(prNumber));
core.setOutput("should_skip", shouldSkip ? "true" : "false");
core.setOutput(
"skip_reason",
shouldSkip ? `bot-authored PR by @${authorLogin}` : "",
);
- name: Write skip summary
if: steps.inspect.outputs.should_skip == 'true'
run: |
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
## Contributor trust automation
- Event: `${{ github.event_name }}`
- PR: #${{ steps.inspect.outputs.pr_number }}
- Author: @${{ steps.inspect.outputs.author_login }}
- Result: skipped ${{ steps.inspect.outputs.skip_reason }}
EOF
trust-score:
name: Score contributor trust
needs: preflight
if: needs.preflight.outputs.should_skip != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout trusted workflow code
uses: actions/checkout@v6
with:
fetch-depth: 1
persist-credentials: false
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.event.repository.default_branch }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Score contributor and sync trust state
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TRUST_PR_NUMBER: ${{ needs.preflight.outputs.pr_number }}
run: node .github/scripts/contributor-trust/run.js

View file

@ -5,9 +5,6 @@ on:
branches:
- main
env:
WXT_SKIP_ENV_VALIDATION: true
jobs:
test-and-build:
name: Test & Build

View file

@ -11,11 +11,6 @@ on:
required: true
type: string
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
@ -126,6 +121,10 @@ jobs:
- name: Build and Zip Extension
if: steps.changesets.outputs.published == 'true' || github.event_name == 'workflow_dispatch'
run: pnpm zip:all
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
- name: Upload Extension Zip to Release
if: steps.changesets.outputs.published == 'true' || github.event_name == 'workflow_dispatch'

View file

@ -18,7 +18,9 @@ jobs:
# ---- Issue settings ----
days-before-issue-stale: 30
days-before-issue-close: 60
exempt-issue-labels: pinned,on-hold
exempt-issue-labels: |
pinned
on-hold
stale-issue-message: |
This issue has been inactive for 30 days and is now marked as stale.
It will be automatically closed in 60 days if no further activity occurs.
@ -35,4 +37,7 @@ jobs:
close-pr-message: |
Since it has been a long time without updates, the PR has been automatically closed.
If you need to continue, please reopen or create a new PR.
exempt-pr-labels: pinned,WIP,on-hold
exempt-pr-labels: |
pinned
WIP
on-hold

View file

@ -13,11 +13,6 @@ on:
default: false
type: boolean
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
jobs:
submit:
name: Submit to Stores
@ -45,6 +40,10 @@ jobs:
- name: Build and Zip Extension
run: pnpm zip
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
- name: Upload Extension Zip to Release
run: gh release upload "${{ inputs.tag }}" .output/*-chrome.zip --clobber

1
.gitignore vendored
View file

@ -29,7 +29,6 @@ web-ext.config.ts
.env
.env.*
!.env.example
!.env.local.example
coverage/

View file

@ -28,16 +28,6 @@
// Add other languages as needed
},
"lsp": {
"vtsls": {
"settings": {
"typescript": {
"tsdk": "./node_modules/typescript/lib"
},
"vtsls": {
"autoUseWorkspaceTsdk": true
}
}
},
"eslint": {
"settings": {
// Remove after https://github.com/zed-industries/zed/issues/49387

View file

@ -1,139 +1,5 @@
# @read-frog/extension
## 1.33.1
### Patch Changes
- [#1394](https://github.com/mengxi-ream/read-frog/pull/1394) [`619c83d`](https://github.com/mengxi-ream/read-frog/commit/619c83defd417ad2c68c8e0c6258afe5e5d79b04) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add embed translate button and settings panel injection
- [#1402](https://github.com/mengxi-ream/read-frog/pull/1402) [`0bd869f`](https://github.com/mengxi-ream/read-frog/commit/0bd869fd935738adcddae76f84c1232313168099) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(extension): guard popup account avatar session state
- [#1397](https://github.com/mengxi-ream/read-frog/pull/1397) [`466c1ce`](https://github.com/mengxi-ream/read-frog/commit/466c1cefdb78726fd870d979ec90c41beafbaa38) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - feat(extension): open native side panel from floating button
- [#1400](https://github.com/mengxi-ream/read-frog/pull/1400) [`c3debfb`](https://github.com/mengxi-ream/read-frog/commit/c3debfbc0c2fe3ebf6c53937c63ca3d745ee4c0e) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - style(options): widen Google Drive sync conflict dialog
## 1.33.0
### Minor Changes
- [#1388](https://github.com/mengxi-ream/read-frog/pull/1388) [`6922155`](https://github.com/mengxi-ream/read-frog/commit/69221554ff0a4db662ce9dff3304ea8923f46c8e) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add subtitle style settings panel with Trancy-inspired UI
- [#1392](https://github.com/mengxi-ream/read-frog/pull/1392) [`596bcf7`](https://github.com/mengxi-ream/read-frog/commit/596bcf7248ddeea7bea843143bcdab52b41a5048) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(extension): support YouTube embed subtitles on third-party sites
### Patch Changes
- [#1385](https://github.com/mengxi-ream/read-frog/pull/1385) [`746a3c5`](https://github.com/mengxi-ream/read-frog/commit/746a3c5c3b71d83a4404db7c26a37c44acc031ae) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(extension): ensure Defuddle webpage context returns Markdown
- [#1391](https://github.com/mengxi-ream/read-frog/pull/1391) [`afa7dee`](https://github.com/mengxi-ream/read-frog/commit/afa7dee1b0b8fcd26559d8a8590e51649166c3a9) Thanks [@li-yiou](https://github.com/li-yiou)! - feat: add floating button controls
- [#1389](https://github.com/mengxi-ream/read-frog/pull/1389) [`c25b299`](https://github.com/mengxi-ream/read-frog/commit/c25b299ca474f3c2baccf9e4a629d6e042dcfbcc) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - style(extension): align primary theme tokens and translation brand colors
## 1.32.4
### Patch Changes
- [#1382](https://github.com/mengxi-ream/read-frog/pull/1382) [`068bdec`](https://github.com/mengxi-ream/read-frog/commit/068bdecc8a3336f2e208a2caeb33412ae4fa45b1) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - perf: replace startup Readability parsing with lightweight page detection
- [#1379](https://github.com/mengxi-ream/read-frog/pull/1379) [`396dd0d`](https://github.com/mengxi-ream/read-frog/commit/396dd0d36b53e67d4815b83bd25418c99f67dac0) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(auth): include credentials for API auth client
- [#1381](https://github.com/mengxi-ream/read-frog/pull/1381) [`810623b`](https://github.com/mengxi-ream/read-frog/commit/810623ba029911b7ab7d1e4db22a5ea3d6867cc5) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - feat(popup): search languages in popup selectors
- [#1377](https://github.com/mengxi-ream/read-frog/pull/1377) [`5b56df8`](https://github.com/mengxi-ream/read-frog/commit/5b56df819abb0e921e8426af97d26e6981b69d29) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - perf(options): persist slider settings after drag commit
- [#1356](https://github.com/mengxi-ream/read-frog/pull/1356) [`4667e3e`](https://github.com/mengxi-ream/read-frog/commit/4667e3eb406fa414cdb6d807682aea28368e548b) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(selection-toolbar): keep modal selections visible when opacity is below 100%
- [#1378](https://github.com/mengxi-ream/read-frog/pull/1378) [`adfc89a`](https://github.com/mengxi-ream/read-frog/commit/adfc89add6f8b0b7d2f6adda5f232d2024e36e94) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(selection-toolbar): keep custom AI action provider switches stable
## 1.32.3
### Patch Changes
- [#1323](https://github.com/mengxi-ream/read-frog/pull/1323) [`da2e94b`](https://github.com/mengxi-ream/read-frog/commit/da2e94bb151e1dca2ca2ac31d777df28210452af) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(selection-toolbar): add more cursor clearance after text selection
- [#1318](https://github.com/mengxi-ream/read-frog/pull/1318) [`74f4219`](https://github.com/mengxi-ream/read-frog/commit/74f42196158be314dc65dc6e9c00b78ab021be23) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(selection-toolbar): derive custom action webpage context by popover session
- [#1336](https://github.com/mengxi-ream/read-frog/pull/1336) [`74f16a9`](https://github.com/mengxi-ream/read-frog/commit/74f16a98d8d8e390ecf8aadc1a5a1db7990310e9) Thanks [@taiiiyang](https://github.com/taiiiyang)! - fix(subtitles): support stylized YouTube karaoke parsing and source export
- [#1324](https://github.com/mengxi-ream/read-frog/pull/1324) [`08b40e8`](https://github.com/mengxi-ream/read-frog/commit/08b40e82cd2c8d7b46e2cac8e1d87672c813fe0b) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix: keep floating button close menu aligned after reopening
- [#1335](https://github.com/mengxi-ream/read-frog/pull/1335) [`fe2eedd`](https://github.com/mengxi-ream/read-frog/commit/fe2eeddc3d49a5554d26454271a8ca27ea16245b) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - fix(models): skip unsupported thinking options for instruct variants
- [#1373](https://github.com/mengxi-ream/read-frog/pull/1373) [`d2c75ac`](https://github.com/mengxi-ream/read-frog/commit/d2c75ace5a4c5c8b6241a4211ac65f443c375c92) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix: open options page in Dia browser
- [#1345](https://github.com/mengxi-ream/read-frog/pull/1345) [`a49ab27`](https://github.com/mengxi-ream/read-frog/commit/a49ab2790bbb39112d67c08a1c8c5f8b22e4a1c8) Thanks [@taiiiyang](https://github.com/taiiiyang)! - fix(subtitles): stabilize YouTube subtitle navigation and popup mounting
- [#1360](https://github.com/mengxi-ream/read-frog/pull/1360) [`01ccdd1`](https://github.com/mengxi-ream/read-frog/commit/01ccdd17a226361eb436ab4fc498c6ac3aeb44c8) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - refactor(env): simplify extension env wiring
- [#1325](https://github.com/mengxi-ream/read-frog/pull/1325) [`0f6bf63`](https://github.com/mengxi-ream/read-frog/commit/0f6bf631ad61088f9c2c8fc27517754ef3dfe565) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - chore(deps): upgrade WXT to 0.20.22 and preserve extension-safe bundle output
- [#1321](https://github.com/mengxi-ream/read-frog/pull/1321) [`fb1937c`](https://github.com/mengxi-ream/read-frog/commit/fb1937c437bcba8ae1eacb181f367e61cc26c3db) Thanks [@yioulii](https://github.com/yioulii)! - fix: floating button style
- [#1372](https://github.com/mengxi-ream/read-frog/pull/1372) [`090463d`](https://github.com/mengxi-ream/read-frog/commit/090463d5887640df1fe4de83b1d40fd3a2175f94) Thanks [@ishiko732](https://github.com/ishiko732)! - docs: update `/tutorial` references to `/docs` to match the website
- [#1368](https://github.com/mengxi-ream/read-frog/pull/1368) [`26b06af`](https://github.com/mengxi-ream/read-frog/commit/26b06af8702ae32420d912666cd66d3348e26e4a) Thanks [@taiiiyang](https://github.com/taiiiyang)! - refactor(subtitles): replace route-based navigation with flat panel navigator
## 1.32.2
### Patch Changes
- [#1317](https://github.com/mengxi-ream/read-frog/pull/1317) [`3500802`](https://github.com/mengxi-ream/read-frog/commit/35008023c6adcabc60903787282c0906873dc107) Thanks [@taiiiyang](https://github.com/taiiiyang)! - i18n(subtitles): rename zh-CN source subtitle download label
- [#1283](https://github.com/mengxi-ream/read-frog/pull/1283) [`219a8d2`](https://github.com/mengxi-ream/read-frog/commit/219a8d29c6a093b822a56cec43e1c3336778e4c9) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add a settings toggle menu for video subtitles
- [#1295](https://github.com/mengxi-ream/read-frog/pull/1295) [`75fafc5`](https://github.com/mengxi-ream/read-frog/commit/75fafc5a3e198667094bdbffa6b77858ce49d499) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - refactor: unify webpage translation context and prompt flow
- [#1287](https://github.com/mengxi-ream/read-frog/pull/1287) [`4a56faa`](https://github.com/mengxi-ream/read-frog/commit/4a56faa44c47f8157528fb9bd734a0e51712004c) Thanks [@ishiko732](https://github.com/ishiko732)! - style: improve selection preview scrolling in the selection toolbar
- [#1297](https://github.com/mengxi-ream/read-frog/pull/1297) [`acdd296`](https://github.com/mengxi-ream/read-frog/commit/acdd296e19681b4d2987f2edc2dbdbedd6cd57c8) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(extension): soften page translation loading spinner with a thinner muted-gray arc
- [#1312](https://github.com/mengxi-ream/read-frog/pull/1312) [`f344e0d`](https://github.com/mengxi-ream/read-frog/commit/f344e0d7dd297d62b33d363ce710ee5ff8fccf1f) Thanks [@taiiiyang](https://github.com/taiiiyang)! - refactor(subtitles): reuse prefetched source subtitles across download and translation
- [#1300](https://github.com/mengxi-ream/read-frog/pull/1300) [`da8d937`](https://github.com/mengxi-ream/read-frog/commit/da8d9376e77939b2ac7be6e85653e467bbcb8019) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(extension): namespace theme tokens to avoid shadow-root css collisions
- [#1299](https://github.com/mengxi-ream/read-frog/pull/1299) [`1464d77`](https://github.com/mengxi-ream/read-frog/commit/1464d774fddc34c950cf7f44852388af65e3e503) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - docs(extension): document the real-browser Edge extension testing workflow for UI verification
- [#1303](https://github.com/mengxi-ream/read-frog/pull/1303) [`3e9f374`](https://github.com/mengxi-ream/read-frog/commit/3e9f374770841d0c03aa66d817958aa5f8035485) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - fix(page-translation): only prime webpage context for AI-aware title translation
- [#1314](https://github.com/mengxi-ream/read-frog/pull/1314) [`788edfb`](https://github.com/mengxi-ream/read-frog/commit/788edfb5ce8ce09f6865726e65f1f67ee68f5433) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(extension): allow the page pre-translate preload distance to be set up to 10000px
- [#1302](https://github.com/mengxi-ream/read-frog/pull/1302) [`f1d9256`](https://github.com/mengxi-ream/read-frog/commit/f1d92569ba224571fae5b221f0432804f1af9f1e) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(tts): change default English voice to Davis and colocate preview button
- [#1313](https://github.com/mengxi-ream/read-frog/pull/1313) [`1951736`](https://github.com/mengxi-ream/read-frog/commit/19517361ceb546989d707bec5fe2675ba1840fb9) Thanks [@taiiiyang](https://github.com/taiiiyang)! - Optimize YouTube subtitle fetching by trying a fast timedtext fetch from the initial player data snapshot before falling back to the slower POT/wait flow.
- [#1307](https://github.com/mengxi-ream/read-frog/pull/1307) [`38be1ed`](https://github.com/mengxi-ream/read-frog/commit/38be1edea97040ecda6753bb557711a76c08aa35) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add source subtitle download as SRT
## 1.32.1
### Patch Changes
- [#1279](https://github.com/mengxi-ream/read-frog/pull/1279) [`b88746d`](https://github.com/mengxi-ream/read-frog/commit/b88746d79b41c7bff0a3dca0739dbf836ca16a08) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(translation): keep reduced-motion spinners visibly active without animation
- [#1262](https://github.com/mengxi-ream/read-frog/pull/1262) [`0e98d55`](https://github.com/mengxi-ream/read-frog/commit/0e98d55f6e3db8a4db7c42814a97dbaa65fc3bac) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(models): broaden Qwen and Kimi model matching
- [#1261](https://github.com/mengxi-ream/read-frog/pull/1261) [`7ea0609`](https://github.com/mengxi-ream/read-frog/commit/7ea06092a9c59e401904fb4353f10f4a3fc70de6) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(provider-options): normalize openai-compatible option aliases
- [#1258](https://github.com/mengxi-ream/read-frog/pull/1258) [`714e44e`](https://github.com/mengxi-ream/read-frog/commit/714e44ed0d81b7a5dafe17b6cadf431aea195bd1) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - fix(providers): set domestic base URLs for minimax and alibaba
- [#1263](https://github.com/mengxi-ream/read-frog/pull/1263) [`e0e78c2`](https://github.com/mengxi-ream/read-frog/commit/e0e78c29ad4ff5cbeeacb8c9eb833b6559be25d5) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - fix(selection-toolbar): avoid hiding focused triggers behind overlays
## 1.32.0
### Minor Changes
- [#1215](https://github.com/mengxi-ream/read-frog/pull/1215) [`dc6fe8e`](https://github.com/mengxi-ream/read-frog/commit/dc6fe8efcd170e4d07e209736bc30236e0b4b23f) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - feat(extension): add save to notebase workflow
### Patch Changes
- [#1242](https://github.com/mengxi-ream/read-frog/pull/1242) [`c253982`](https://github.com/mengxi-ream/read-frog/commit/c2539821fba5acc399e4cc056a67765e5d28b84d) Thanks [@kilidoc](https://github.com/kilidoc)! - fix: storage false value reset and backup delete dialog not showing
- [#1195](https://github.com/mengxi-ream/read-frog/pull/1195) [`ce61cc9`](https://github.com/mengxi-ream/read-frog/commit/ce61cc9daa43dd60792951c784cc2d8ba1bf3e84) Thanks [@taiiiyang](https://github.com/taiiiyang)! - perf(subtitles): decouple AI smart context summary from translation
- [#1253](https://github.com/mengxi-ream/read-frog/pull/1253) [`aacbe36`](https://github.com/mengxi-ream/read-frog/commit/aacbe367a1241982ffa9e88ddc09bab00703ab2f) Thanks [@pooneyy](https://github.com/pooneyy)! - feat(models): update minimax model list and default model
- [#1257](https://github.com/mengxi-ream/read-frog/pull/1257) [`8d3baa8`](https://github.com/mengxi-ream/read-frog/commit/8d3baa88dee92081b8467eda89b93e43cd10c4f9) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - fix(extension): preserve shared popup close-state behavior in builds
- [#1230](https://github.com/mengxi-ream/read-frog/pull/1230) [`b2173e8`](https://github.com/mengxi-ream/read-frog/commit/b2173e8efa6c3d2307fcd06168879c9e81662096) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(extension): guard notebase beta access
## 1.31.4
### Patch Changes

192
CLAUDE.md Normal file
View file

@ -0,0 +1,192 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Read Frog is an open-source AI-powered language learning browser extension that provides immersive translation, article analysis, and contextual learning features.
## Design Principles
- **SOLID**
- **S**ingle Responsibility: one reason to change per unit.
- **O**pen/Closed: extend via **composition**; avoid modifying stable core.
- **L**iskov: subtypes must be true drop-ins.
- **I**nterface Segregation: small, focused interfaces.
- **D**ependency Inversion: depend on abstractions; wire via DI.
- **DRY, KISS, YAGNI**
- No duplication; simplest thing that could work; dont build for imaginary futures.
- **Functional Lean**
- Prefer **pure functions**, **immutability**, and **clear boundaries** (ports & adapters / hexagonal).
## Requirements Confirmation Process
Whenever users express demands, must follow these steps:
1. **Thinking Prerequisites - Linus's Three Questions**
Before starting any analysis, ask yourself:
```text
1. "Is this a real problem or an imagined one?" - Reject over-engineering
2. "Is there a simpler way?" - Always seek the simplest solution
3. "Will it break anything?" - Backward compatibility is an iron law
```
2. **Requirements Understanding Confirmation**
```text
Based on existing information, I understand your requirement is: [Restate requirement using Linus's thinking communication style]
Please confirm if my understanding is accurate?
```
2. **Linus-style Problem Decomposition Thinking**
**Layer 1: Data Structure Analysis**
```text
"Bad programmers worry about the code. Good programmers worry about data structures."
- What is the core data? How are they related?
- Where does the data flow? Who owns it? Who modifies it?
- Are there unnecessary data copying or transformations?
```
**Layer 2: Special Case Identification**
```text
"Good code has no special cases"
- Find all if/else branches
- Which are real business logic? Which are patches for bad design?
- Can we redesign data structures to eliminate these branches?
```
**Layer 3: Complexity Review**
```text
"If implementation requires more than 3 levels of indentation, redesign it"
- What is the essence of this feature? (Explain in one sentence)
- How many concepts does the current solution use?
- Can we reduce it to half? Half again?
```
**Layer 4: Destructive Analysis**
```text
"Never break userspace" - Backward compatibility is an iron law
- List all existing features that might be affected
- Which dependencies will be broken?
- How to improve without breaking anything?
```
**Layer 5: Practicality Verification**
```text
"Theory and practice sometimes clash. Theory loses. Every single time."
- Does this problem really exist in production?
- How many users actually encounter this problem?
- Does the complexity of the solution match the severity of the problem?
```
3. **Decision Output Pattern**
After the above 5 layers of thinking, output must include (format can be prettier align with markdown of Github):
```text
【Core Judgment】
✅ Worth doing: [reason] / ❌ Not worth doing: [reason]
【Key Insights】
- Data structure: [most critical data relationships]
- Complexity: [complexity that can be eliminated]
- Risk points: [biggest destructive risks]
【Linus-style Solution】
If worth doing:
1. First step is always to simplify data structures
2. Eliminate all special cases
3. Implement in the stupidest but clearest way
4. Ensure zero destructiveness
If not worth doing:
"This is solving a non-existent problem. The real problem is [XXX]."
```
4. **Code Review Output**
When seeing code, immediately make three-layer judgment:
```text
【Taste Score】
🟢 Good taste / 🟡 Acceptable / 🔴 Poor quality
【Fatal Issues】
- [If any, directly point out the worst parts]
【Improvement Direction】
"Eliminate this special case"
"These 10 lines can become 3 lines"
"Data structure is wrong, should be..."
```
## Tech Stack
- **Framework**: [WXT](https://wxt.dev/) - Modern browser extension framework with Manifest V3
- **UI**: React 19, TailwindCSS 4, Radix UI, shadcn/ui components
- **Language**: TypeScript
- **State Management**: Jotai with custom storage adapter for extension storage sync
- **Database**: Dexie (IndexedDB wrapper) for local data persistence
- **AI Integration**: Vercel AI SDK with 20+ provider integrations
- **Testing**: Vitest with Istanbul coverage
- **Build**: Vite-based bundler (via WXT)
- **i18n**: @wxt-dev/i18n with YML locale files in `src/locales/`
## Development Commands
### Core Development
```bash
# Start development mode (Chrome)
pnpm dev
# Start development with local monorepo packages
pnpm dev:local
```
### Testing and Quality
```bash
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run tests with coverage
pnpm test:cov
# Type checking
pnpm type-check
# Linting
pnpm lint
pnpm lint:fix
```
## Commit Conventions
Follow Conventional Commits with these types:
Standard types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
Custom types: `i18n`, `ai`
## Important Notes
As AI, you should be extremely concise. Sacrifice grammar for the sake of concision.

View file

@ -6,7 +6,7 @@ An open-source AI-powered language learning extension for browsers.<br/>
Supports immersive translation, article analysis, multiple AI models, and more.<br/>
Master languages effortlessly and deeply with AI, right in your browser.
**English** · [简体中文](./README.zh-CN.md) · [Official Website](https://readfrog.app) · [Tutorial](https://www.readfrog.app/docs) · [Changelog](https://www.readfrog.app/changelog) · [Blog](https://www.readfrog.app/blog)
**English** · [简体中文](./README.zh-CN.md) · [Official Website](https://readfrog.app) · [Tutorial](https://www.readfrog.app/tutorial) · [Changelog](https://www.readfrog.app/changelog) · [Blog](https://www.readfrog.app/blog)
<!-- SHIELD GROUP -->
@ -46,6 +46,7 @@ Master languages effortlessly and deeply with AI, right in your browser.
- [🤖 20+ AI Providers](#-20-ai-providers)
- [🎬 Subtitle Translation](#-subtitle-translation)
- [🔊 Text-to-Speech (TTS)](#-text-to-speech-tts)
- [📖 Read Article](#-read-article)
- [🤝 Contribute](#-contribute)
- [Contribute Code](#contribute-code)
- [📜 Commercial License Grant](#-commercial-license-grant)
@ -133,7 +134,7 @@ The extension automatically re-translates all visible content when you switch mo
### 🧠 [Context-Aware Translation][docs-tutorial]
Enable AI to understand the full context of what you're reading. When activated, Read Frog extracts the page title and a concise Markdown version of the page content, providing this context to the AI for more accurate, contextually-appropriate translations.
Enable AI to understand the full context of what you're reading. When activated, Read Frog uses Mozilla's Readability library to extract the article's title and content, providing this context to the AI for more accurate, contextually-appropriate translations.
This means technical terms get translated correctly within their domain, literary expressions maintain their nuance, and ambiguous phrases are interpreted based on the surrounding content rather than in isolation.
@ -225,6 +226,20 @@ Automatic language detection (basic or LLM-powered) with per-language voice mapp
</div>
<!-- ![][image-feat-read] -->
### 📖 [Read Article][docs-tutorial]
One-click deep article analysis. Read Frog extracts the main content using Mozilla's Readability, detects the source language, and generates a summary and introduction in your target language.
Then it provides sentence-by-sentence translations with vocabulary explanations tailored to your language level (beginner, intermediate, or advanced). Each sentence includes key word definitions, grammatical analysis, and contextual explanations. It's like having a personal language tutor analyze every article you read.
<div align="right">
[![Back to top][back-to-top]](#readme-top)
</div>
## 🤝 Contribute
Contributions of all types are more than welcome.
@ -239,7 +254,7 @@ Project Structure: [DeepWiki](https://deepwiki.com/mengxi-ream/read-frog)
Ask AI to understand the project: [Dosu](https://app.dosu.dev/29569286-71ba-47dd-b038-c7ab1b9d0df7/documents)
Check out the [Contribution Guide](https://readfrog.app/en/docs/code-contribution/contribution-guide) for more details.
Check out the [Contribution Guide](https://readfrog.app/en/tutorial/code-contribution/contribution-guide) for more details.
ReadFrog is dual-licensed under GPLv3 and a commercial license.
@ -293,6 +308,8 @@ Every donation helps us build a better language learning experience. Thank you f
[![Sponsors][sponsor-image]][sponsor-link]
(will support Afdian in the future)
<div align="right">
[![Back to top][back-to-top]](#readme-top)
@ -336,4 +353,4 @@ Every donation helps us build a better language learning experience. Thank you f
<!-- Feature docs link -->
[docs-tutorial]: https://readfrog.app/docs
[docs-tutorial]: https://readfrog.app/tutorial

View file

@ -6,7 +6,7 @@
支持沉浸式翻译、文章分析、多种 AI 模型等功能。<br/>
在浏览器中利用 AI 轻松深入地掌握语言。
[English](./README.md) · **简体中文** · [官方网站](https://readfrog.app) · [教程](https://www.readfrog.app/zh/docs) · [更新日志](https://www.readfrog.app/zh/changelog) · [博客](https://www.readfrog.app/zh/blog)
[English](./README.md) · **简体中文** · [官方网站](https://readfrog.app) · [教程](https://www.readfrog.app/zh/tutorial) · [更新日志](https://www.readfrog.app/zh/changelog) · [博客](https://www.readfrog.app/zh/blog)
<!-- SHIELD GROUP -->
@ -46,6 +46,7 @@
- [🤖 20+ AI 服务商](#-20-ai-服务商)
- [🎬 字幕翻译](#-字幕翻译)
- [🔊 文字转语音 (TTS)](#-文字转语音-tts)
- [📖 阅读文章](#-阅读文章)
- [🤝 贡献](#-贡献)
- [贡献代码](#贡献代码)
- [📜 商业授权](#-商业授权)
@ -133,7 +134,7 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
### 🧠 [上下文感知翻译][docs-tutorial]
让 AI 理解您正在阅读内容的完整上下文。启用后Read Frog 会提取页面标题和简洁的 Markdown 页面内容,将此上下文提供给 AI以获得更准确、更符合语境的翻译。
让 AI 理解您正在阅读内容的完整上下文。启用后Read Frog 使用 Mozilla 的 Readability 库提取文章的标题和内容,将此上下文提供给 AI以获得更准确、更符合语境的翻译。
这意味着技术术语会在其领域内被正确翻译,文学表达会保持其韵味,歧义短语会根据周围内容而非孤立地进行解释。
@ -225,6 +226,20 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
</div>
<!-- ![][image-feat-read] -->
### 📖 [阅读文章][docs-tutorial]
一键深度文章分析。Read Frog 使用 Mozilla 的 Readability 提取主要内容,检测源语言,并用您的目标语言生成摘要和导读。
然后提供逐句翻译,配合根据您的语言水平(初级、中级或高级)定制的词汇解释。每个句子都包含关键词定义、语法分析和上下文解释。就像有一位私人语言导师分析您阅读的每篇文章。
<div align="right">
[![Back to top][back-to-top]](#readme-top)
</div>
## 🤝 贡献
我们欢迎各种类型的贡献。
@ -237,7 +252,7 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
通过 AI 了解项目:[DeepWiki](https://deepwiki.com/mengxi-ream/read-frog)
查看[贡献指南](https://readfrog.app/zh/docs/code-contribution/contribution-guide)了解更多详情。
查看[贡献指南](https://readfrog.app/zh/tutorial/code-contribution/contribution-guide)了解更多详情。
ReadFrog 采用 GPLv3 和商业许可双重授权。
@ -334,4 +349,4 @@ ReadFrog 采用 GPLv3 和商业许可双重授权。
<!-- Feature docs link -->
[docs-tutorial]: https://readfrog.app/zh/docs
[docs-tutorial]: https://readfrog.app/zh/tutorial

View file

@ -98,16 +98,4 @@ export default antfu({
"test/no-identical-title": "error",
"test/prefer-hooks-on-top": "error",
},
}).append({
files: [
"**/__tests__/**/*.ts",
"**/__tests__/**/*.tsx",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
],
rules: {
"react/component-hook-factories": "off",
},
})

View file

@ -1,90 +1,89 @@
{
"name": "@read-frog/extension",
"type": "module",
"version": "1.33.1",
"version": "1.31.4",
"private": true,
"packageManager": "pnpm@10.32.1",
"description": "Read Frog browser extension for language learning",
"scripts": {
"build": "wxt build",
"build:edge": "wxt build -b edge",
"build:firefox": "wxt build -b firefox --mv3",
"build": "DOTENV_CONFIG_QUIET=false wxt build",
"build:edge": "DOTENV_CONFIG_QUIET=false wxt build -b edge",
"build:firefox": "DOTENV_CONFIG_QUIET=false wxt build -b firefox --mv3",
"build:analyze": "wxt build --analyze",
"dev:local": "WXT_USE_LOCAL_PACKAGES=true wxt",
"dev": "wxt",
"dev:edge": "wxt -b edge",
"dev:firefox": "wxt -b firefox --mv3",
"dev": "DOTENV_CONFIG_QUIET=false wxt",
"dev:edge": "DOTENV_CONFIG_QUIET=false wxt -b edge",
"dev:firefox": "DOTENV_CONFIG_QUIET=false wxt -b firefox --mv3",
"lint": "eslint",
"lint:fix": "eslint --fix",
"postinstall": "WXT_SKIP_ENV_VALIDATION=true wxt prepare",
"postinstall": "wxt prepare",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"scrape:ai-sdk-models": "npx tsx scripts/scrape-ai-sdk-provider-models.ts --out scripts/output/ai-sdk-provider-models.json",
"zip": "wxt zip",
"zip:edge": "wxt zip -b edge",
"zip:firefox": "wxt zip -b firefox --mv3",
"zip:all": "pnpm zip && pnpm zip:edge && pnpm zip:firefox",
"zip": "DOTENV_CONFIG_QUIET=false WXT_ZIP_MODE=true wxt zip",
"zip:edge": "DOTENV_CONFIG_QUIET=false WXT_ZIP_MODE=true wxt zip -b edge",
"zip:firefox": "DOTENV_CONFIG_QUIET=false WXT_ZIP_MODE=true wxt zip -b firefox --mv3",
"zip:all": "DOTENV_CONFIG_QUIET=false pnpm zip && pnpm zip:edge && pnpm zip:firefox",
"release": "changeset tag && git push origin --tags",
"prepare": "husky"
},
"dependencies": {
"@ai-sdk/alibaba": "^1.0.17",
"@ai-sdk/amazon-bedrock": "^4.0.96",
"@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/cerebras": "^2.0.45",
"@ai-sdk/cohere": "^3.0.30",
"@ai-sdk/deepinfra": "^2.0.45",
"@ai-sdk/deepseek": "^2.0.29",
"@ai-sdk/fireworks": "^2.0.46",
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/groq": "^3.0.35",
"@ai-sdk/huggingface": "^1.0.43",
"@ai-sdk/mistral": "^3.0.30",
"@ai-sdk/moonshotai": "^2.0.16",
"@ai-sdk/openai": "^3.0.53",
"@ai-sdk/openai-compatible": "^2.0.41",
"@ai-sdk/perplexity": "^3.0.29",
"@ai-sdk/react": "^3.0.170",
"@ai-sdk/replicate": "^2.0.29",
"@ai-sdk/togetherai": "^2.0.45",
"@ai-sdk/vercel": "^2.0.43",
"@ai-sdk/xai": "^3.0.83",
"@babel/runtime": "^7.29.2",
"@base-ui/react": "^1.4.1",
"@ai-sdk/alibaba": "^1.0.13",
"@ai-sdk/amazon-bedrock": "^4.0.83",
"@ai-sdk/anthropic": "^3.0.64",
"@ai-sdk/cerebras": "^2.0.41",
"@ai-sdk/cohere": "^3.0.27",
"@ai-sdk/deepinfra": "^2.0.41",
"@ai-sdk/deepseek": "^2.0.26",
"@ai-sdk/fireworks": "^2.0.42",
"@ai-sdk/google": "^3.0.53",
"@ai-sdk/groq": "^3.0.31",
"@ai-sdk/huggingface": "^1.0.39",
"@ai-sdk/mistral": "^3.0.27",
"@ai-sdk/moonshotai": "^2.0.12",
"@ai-sdk/openai": "^3.0.48",
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/perplexity": "^3.0.26",
"@ai-sdk/react": "^3.0.140",
"@ai-sdk/replicate": "^2.0.26",
"@ai-sdk/togetherai": "^2.0.41",
"@ai-sdk/vercel": "^2.0.39",
"@ai-sdk/xai": "^3.0.74",
"@base-ui/react": "^1.3.0",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1",
"@codemirror/view": "^6.40.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headless-tree/core": "^1.6.3",
"@headless-tree/react": "^1.6.3",
"@json-render/core": "^0.18.0",
"@json-render/react": "^0.18.0",
"@openrouter/ai-sdk-provider": "^2.8.0",
"@orpc/client": "^1.14.0",
"@orpc/tanstack-query": "^1.14.0",
"@json-render/core": "^0.15.0",
"@json-render/react": "^0.15.0",
"@mozilla/readability": "^0.6.0",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@orpc/client": "^1.13.10",
"@orpc/tanstack-query": "^1.13.10",
"@radix-ui/react-slot": "^1.2.4",
"@read-frog/api-contract": "0.4.1",
"@read-frog/definitions": "0.1.4",
"@read-frog/api-contract": "0.2.2",
"@read-frog/definitions": "0.1.2",
"@remixicon/react": "^4.9.0",
"@t3-oss/env-core": "^0.13.11",
"@tabler/icons-react": "^3.41.1",
"@tanstack/hotkeys": "^0.7.1",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-hotkeys": "^0.9.1",
"@tanstack/react-query": "^5.100.1",
"@uiw/codemirror-extensions-color": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
"@tabler/icons-react": "^3.40.0",
"@tanstack/hotkeys": "^0.4.3",
"@tanstack/react-form": "^1.28.5",
"@tanstack/react-hotkeys": "^0.5.1",
"@tanstack/react-query": "^5.95.2",
"@uiw/codemirror-extensions-color": "^4.25.8",
"@uiw/react-codemirror": "^4.25.8",
"@webext-core/messaging": "^2.3.0",
"@wxt-dev/i18n": "^0.2.5",
"ai": "^6.0.168",
"better-auth": "^1.6.8",
"ai": "^6.0.138",
"better-auth": "^1.5.6",
"case-anything": "^3.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -92,24 +91,23 @@
"css-tree": "^3.2.1",
"debounce": "^3.0.0",
"deepmerge-ts": "^7.1.5",
"defuddle": "^0.18.1",
"dequal": "^2.0.3",
"dexie": "^4.4.2",
"dexie": "^4.3.0",
"file-saver": "^2.0.5",
"franc": "^6.2.0",
"jotai": "^2.19.1",
"jotai": "^2.18.1",
"jotai-family": "^1.0.1",
"js-sha256": "^0.11.1",
"ollama-ai-provider-v2": "^3.5.0",
"posthog-js": "^1.371.2",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"posthog-js": "^1.363.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1",
"react-markdown": "^10.1.0",
"react-rnd": "^10.5.3",
"react-router": "^7.14.2",
"recharts": "^3.8.1",
"shadcn": "^4.4.0",
"react-router": "^7.13.2",
"recharts": "^3.8.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@ -117,17 +115,17 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@antfu/eslint-config": "^8.2.0",
"@antfu/eslint-config": "^7.7.3",
"@changesets/changelog-github": "^0.6.0",
"@changesets/cli": "^2.31.0",
"@changesets/cli": "^2.30.0",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint-react/eslint-plugin": "^3.0.0",
"@eslint-react/eslint-plugin": "^2.13.0",
"@faker-js/faker": "^10.4.0",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.4",
"@tanstack/eslint-plugin-query": "^5.99.2",
"@tanstack/react-query-devtools": "^5.99.2",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/eslint-plugin-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@total-typescript/ts-reset": "^0.6.1",
@ -137,26 +135,27 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-istanbul": "^4.1.5",
"@vitest/coverage-istanbul": "^4.1.2",
"@wxt-dev/module-react": "^1.2.2",
"autoprefixer": "^10.5.0",
"autoprefixer": "^10.4.27",
"eruda": "^3.4.3",
"eslint": "^10.2.1",
"eslint": "^10.1.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"husky": "^9.1.7",
"jsdom": "^29.0.2",
"jsdom": "^29.0.1",
"lint-staged": "^16.4.0",
"nx": "^22.6.5",
"postcss": "^8.5.10",
"postcss-rem-to-responsive-pixel": "^7.0.4",
"nx": "^22.6.2",
"postcss": "^8.5.8",
"postcss-rem-to-responsive-pixel": "^7.0.1",
"postcss-value-parser": "^4.2.0",
"tailwindcss": "^4.2.4",
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wxt": "0.20.25"
"tailwindcss": "^4.2.2",
"type-fest": "^5.5.0",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vitest": "^4.1.2",
"wxt": "0.20.20"
},
"devEngines": {
"runtime": {

5712
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(8 8) scale(0.75) translate(-8 -8)" d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
</svg>
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
</svg>

Before

Width:  |  Height:  |  Size: 565 B

After

Width:  |  Height:  |  Size: 510 B

Before After
Before After

View file

@ -1 +1 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M22.75 17.5C22.75 17.91 22.41 18.25 22 18.25H15V18.5C15 20 14.1 20.5 13 20.5H7C5.9 20.5 5 20 5 18.5V18.25H2C1.59 18.25 1.25 17.91 1.25 17.5C1.25 17.09 1.59 16.75 2 16.75H5V16.5C5 15 5.9 14.5 7 14.5H13C14.1 14.5 15 15 15 16.5V16.75H22C22.41 16.75 22.75 17.09 22.75 17.5Z" fill="oklch(76.034% 0.12361 82.191)"></path> <path opacity="0.4" d="M22.75 6.5C22.75 6.91 22.41 7.25 22 7.25H19V7.5C19 9 18.1 9.5 17 9.5H11C9.9 9.5 9 9 9 7.5V7.25H2C1.59 7.25 1.25 6.91 1.25 6.5C1.25 6.09 1.59 5.75 2 5.75H9V5.5C9 4 9.9 3.5 11 3.5H17C18.1 3.5 19 4 19 5.5V5.75H22C22.41 5.75 22.75 6.09 22.75 6.5Z" fill="oklch(76.034% 0.12361 82.191)"></path> </g></svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M22.75 17.5C22.75 17.91 22.41 18.25 22 18.25H15V18.5C15 20 14.1 20.5 13 20.5H7C5.9 20.5 5 20 5 18.5V18.25H2C1.59 18.25 1.25 17.91 1.25 17.5C1.25 17.09 1.59 16.75 2 16.75H5V16.5C5 15 5.9 14.5 7 14.5H13C14.1 14.5 15 15 15 16.5V16.75H22C22.41 16.75 22.75 17.09 22.75 17.5Z" fill="oklch(69.6% 0.17 162.48)"></path> <path opacity="0.4" d="M22.75 6.5C22.75 6.91 22.41 7.25 22 7.25H19V7.5C19 9 18.1 9.5 17 9.5H11C9.9 9.5 9 9 9 7.5V7.25H2C1.59 7.25 1.25 6.91 1.25 6.5C1.25 6.09 1.59 5.75 2 5.75H9V5.5C9 4 9.9 3.5 11 3.5H17C18.1 3.5 19 4 19 5.5V5.75H22C22.41 5.75 22.75 6.09 22.75 6.5Z" fill="oklch(69.6% 0.17 162.48)"></path> </g></svg>

Before

Width:  |  Height:  |  Size: 876 B

After

Width:  |  Height:  |  Size: 866 B

Before After
Before After

View file

@ -0,0 +1,11 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
const translationNodePresetCss = readFileSync(new URL("../translation-node-preset.css", import.meta.url), "utf8")
describe("translation-node-preset.css", () => {
it("keeps a float-wrap override for block translations", () => {
expect(translationNodePresetCss).toContain(".read-frog-translated-block-content[data-read-frog-float-wrap=\"true\"]")
expect(translationNodePresetCss).toContain("display: block !important;")
})
})

View file

@ -14,7 +14,7 @@
}
.read-frog-translated-block-content[data-read-frog-custom-translation-style="blockquote"] {
border-left: 4px solid var(--read-frog-brand);
border-left: 4px solid var(--read-frog-primary);
padding: 4px 0 4px 8px;
}
@ -24,22 +24,22 @@
}
[data-read-frog-custom-translation-style="dashedLine"] {
text-decoration: underline dashed var(--read-frog-brand) !important;
text-decoration: underline dashed var(--read-frog-primary) !important;
text-underline-offset: 5px;
}
[data-read-frog-custom-translation-style="border"] {
border: 1px solid var(--read-frog-brand);
border: 1px solid var(--read-frog-primary);
padding: 2px 4px;
border-radius: 4px;
}
[data-read-frog-custom-translation-style="textColor"] {
color: var(--read-frog-brand) !important;
color: var(--read-frog-primary) !important;
}
[data-read-frog-custom-translation-style="background"] {
background-color: color-mix(in srgb, var(--read-frog-brand) 15%, transparent);
background-color: color-mix(in srgb, var(--read-frog-primary) 15%, transparent);
padding: 2px 4px;
border-radius: 4px;
}

View file

@ -1,16 +1,12 @@
:root {
--read-frog-primary: oklch(0.205 0 0);
--read-frog-brand: oklch(76.034% 0.12361 82.191);
--read-frog-foreground: oklch(0.985 0 0);
--read-frog-primary: oklch(76.5% 0.177 163.223);
--read-frog-muted: oklch(0.97 0 0);
--read-frog-muted-foreground: oklch(0.556 0 0);
}
@media (prefers-color-scheme: dark) {
:root {
--read-frog-primary: oklch(0.922 0 0);
--read-frog-brand: oklch(76.034% 0.12361 82.191);
--read-frog-foreground: oklch(0.205 0 0);
--read-frog-primary: oklch(59.6% 0.145 163.225);
--read-frog-muted: oklch(0.269 0 0);
--read-frog-muted-foreground: oklch(0.708 0 0);
}

View file

@ -8,139 +8,129 @@
@custom-variant data-vertical (&[data-orientation="vertical"]);
@theme inline {
/* Use read-frog-prefixed theme tokens to avoid collisions with host-page custom properties inside shadow roots. */
--font-sans: var(--rf-font-sans);
--font-mono: var(--rf-font-mono);
--color-background: var(--rf-background);
--color-foreground: var(--rf-foreground);
--color-card: var(--rf-card);
--color-card-foreground: var(--rf-card-foreground);
--color-popover: var(--rf-popover);
--color-popover-foreground: var(--rf-popover-foreground);
--color-primary: var(--rf-primary);
--color-primary-foreground: var(--rf-primary-foreground);
--color-brand: var(--rf-brand);
--color-brand-foreground: var(--rf-brand-foreground);
--color-secondary: var(--rf-secondary);
--color-secondary-foreground: var(--rf-secondary-foreground);
--color-muted: var(--rf-muted);
--color-muted-foreground: var(--rf-muted-foreground);
--color-accent: var(--rf-accent);
--color-accent-foreground: var(--rf-accent-foreground);
--color-destructive: var(--rf-destructive);
--color-destructive-foreground: var(--rf-destructive-foreground);
--color-border: var(--rf-border);
--color-input: var(--rf-input);
--color-ring: var(--rf-ring);
--color-chart-1: var(--rf-chart-1);
--color-chart-2: var(--rf-chart-2);
--color-chart-3: var(--rf-chart-3);
--color-chart-4: var(--rf-chart-4);
--color-chart-5: var(--rf-chart-5);
--color-sidebar: var(--rf-sidebar);
--color-sidebar-foreground: var(--rf-sidebar-foreground);
--color-sidebar-primary: var(--rf-sidebar-primary);
--color-sidebar-primary-foreground: var(--rf-sidebar-primary-foreground);
--color-sidebar-accent: var(--rf-sidebar-accent);
--color-sidebar-accent-foreground: var(--rf-sidebar-accent-foreground);
--color-sidebar-border: var(--rf-sidebar-border);
--color-sidebar-ring: var(--rf-sidebar-ring);
--radius-sm: calc(var(--rf-radius) - 4px);
--radius-md: calc(var(--rf-radius) - 2px);
--radius-lg: var(--rf-radius);
--radius-xl: calc(var(--rf-radius) + 4px);
--radius-2xl: calc(var(--rf-radius) + 8px);
--radius-3xl: calc(var(--rf-radius) + 12px);
--radius-4xl: calc(var(--rf-radius) + 16px);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* Additional theme variables */
--color-warning: var(--rf-warning);
--color-warning-foreground: var(--rf-warning-foreground);
--color-link: var(--rf-link);
--shadow-floating: var(--rf-elevation-floating);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-link: var(--link);
--shadow-floating: var(--elevation-floating);
}
:root {
--rf-font-sans:
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--rf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--rf-background: oklch(1 0 0);
--rf-foreground: oklch(0.141 0.005 285.823);
--rf-card: oklch(1 0 0);
--rf-card-foreground: oklch(0.141 0.005 285.823);
--rf-popover: oklch(1 0 0);
--rf-popover-foreground: oklch(0.141 0.005 285.823);
--rf-primary: oklch(0.205 0 0);
--rf-primary-foreground: oklch(0.985 0 0);
--rf-brand: oklch(76.034% 0.12361 82.191);
--rf-brand-foreground: oklch(0.985 0 0);
--rf-secondary: oklch(0.967 0.001 286.375);
--rf-secondary-foreground: oklch(0.47 0.006 285.885);
--rf-muted: oklch(0.967 0.001 286.375);
--rf-muted-foreground: oklch(0.552 0.016 285.938);
--rf-accent: oklch(0.967 0.001 286.375);
--rf-accent-foreground: oklch(0.21 0.006 285.885);
--rf-destructive: oklch(0.577 0.245 27.325);
--rf-border: oklch(0.92 0.004 286.32);
--rf-input: oklch(0.92 0.004 286.32);
--rf-ring: oklch(0.705 0.015 286.067);
--rf-chart-1: oklch(89.215% 0.05562 82.191);
--rf-chart-2: oklch(82.026% 0.09271 82.191);
--rf-chart-3: oklch(76.034% 0.12361 82.191);
--rf-chart-4: oklch(57.026% 0.09271 82.191);
--rf-chart-5: oklch(38.017% 0.06181 82.191);
--rf-radius: 0.625rem;
--rf-sidebar: oklch(0.985 0 0);
--rf-sidebar-foreground: oklch(0.141 0.005 285.823);
--rf-sidebar-primary: oklch(0.205 0 0);
--rf-sidebar-primary-foreground: oklch(0.985 0 0);
--rf-sidebar-accent: oklch(0.967 0.001 286.375);
--rf-sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--rf-sidebar-border: oklch(0.92 0.004 286.32);
--rf-sidebar-ring: oklch(0.705 0.015 286.067);
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.693 0.17 162.48);
--primary-foreground: oklch(0.98 0.02 166);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.47 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.85 0.13 165);
--chart-2: oklch(0.77 0.15 163);
--chart-3: oklch(0.7 0.15 162);
--chart-4: oklch(0.6 0.13 163);
--chart-5: oklch(0.51 0.1 166);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.6 0.13 163);
--sidebar-primary-foreground: oklch(0.98 0.02 166);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
/* Additional theme variables */
--rf-warning: oklch(97.3% 0.071 103.193);
--rf-link: oklch(54.6% 0.245 262.881);
--rf-elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.08), 0 0 1px 0 rgb(0 0 0 / 0.08);
--warning: oklch(97.3% 0.071 103.193);
--link: oklch(54.6% 0.245 262.881);
--elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.08), 0 0 1px 0 rgb(0 0 0 / 0.08);
}
.dark {
--rf-background: oklch(0.141 0.005 285.823);
--rf-foreground: oklch(0.985 0 0);
--rf-card: oklch(0.21 0.006 285.885);
--rf-card-foreground: oklch(0.985 0 0);
--rf-popover: oklch(0.21 0.006 285.885);
--rf-popover-foreground: oklch(0.985 0 0);
--rf-primary: oklch(0.922 0 0);
--rf-primary-foreground: oklch(0.205 0 0);
--rf-brand: oklch(76.034% 0.12361 82.191);
--rf-brand-foreground: oklch(0.205 0 0);
--rf-secondary: oklch(0.274 0.006 286.033);
--rf-secondary-foreground: oklch(0.775 0 0);
--rf-muted: oklch(0.274 0.006 286.033);
--rf-muted-foreground: oklch(0.705 0.015 286.067);
--rf-accent: oklch(0.274 0.006 286.033);
--rf-accent-foreground: oklch(0.985 0 0);
--rf-destructive: oklch(0.704 0.191 22.216);
--rf-border: oklch(1 0 0 / 10%);
--rf-input: oklch(1 0 0 / 15%);
--rf-ring: oklch(0.552 0.016 285.938);
--rf-chart-1: oklch(38.017% 0.06181 82.191);
--rf-chart-2: oklch(57.026% 0.09271 82.191);
--rf-chart-3: oklch(76.034% 0.12361 82.191);
--rf-chart-4: oklch(82.026% 0.09271 82.191);
--rf-chart-5: oklch(89.215% 0.05562 82.191);
--rf-sidebar: oklch(0.21 0.006 285.885);
--rf-sidebar-foreground: oklch(0.985 0 0);
--rf-sidebar-primary: oklch(0.922 0 0);
--rf-sidebar-primary-foreground: oklch(0.205 0 0);
--rf-sidebar-accent: oklch(0.274 0.006 286.033);
--rf-sidebar-accent-foreground: oklch(0.985 0 0);
--rf-sidebar-border: oklch(1 0 0 / 10%);
--rf-sidebar-ring: oklch(0.552 0.016 285.938);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.693 0.17 162.48);
--primary-foreground: oklch(0.26 0.05 173);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.775 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.85 0.13 165);
--chart-2: oklch(0.77 0.15 163);
--chart-3: oklch(0.7 0.15 162);
--chart-4: oklch(0.6 0.13 163);
--chart-5: oklch(0.51 0.1 166);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.77 0.15 163);
--sidebar-primary-foreground: oklch(0.26 0.05 173);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
/* Additional theme variables */
--rf-warning: oklch(42.1% 0.095 57.708);
--rf-link: oklch(70.7% 0.165 254.624);
--rf-elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.28), 0 0 1px 0 rgb(0 0 0 / 0.28);
--warning: oklch(42.1% 0.095 57.708);
--link: oklch(70.7% 0.165 254.624);
--elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.28), 0 0 1px 0 rgb(0 0 0 / 0.28);
}
@layer base {

View file

@ -1,115 +0,0 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react"
import { render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import guest from "@/assets/icons/avatars/guest.svg"
const { sessionState, useSessionMock } = vi.hoisted(() => ({
sessionState: {
data: null as unknown,
isPending: false,
},
useSessionMock: vi.fn(() => sessionState),
}))
vi.mock("@/env", () => ({
env: {
WXT_WEBSITE_URL: "https://readfrog.app",
},
}))
vi.mock("@/utils/auth/auth-client", () => ({
authClient: {
useSession: useSessionMock,
},
}))
vi.mock("@/components/ui/base-ui/avatar", () => ({
Avatar: ({
children,
className,
size = "default",
}: ComponentProps<"span"> & { size?: "default" | "sm" | "lg" }) => (
<span data-slot="avatar" data-size={size} className={className}>
{children}
</span>
),
AvatarImage: (props: ComponentProps<"img">) => props.src ? <img data-slot="avatar-image" {...props} /> : null,
AvatarFallback: ({ children }: ComponentProps<"span">) => (
<span data-slot="avatar-fallback">{children}</span>
),
}))
describe("user account", () => {
beforeEach(() => {
vi.clearAllMocks()
sessionState.data = null
sessionState.isPending = false
})
it("shows the guest image and login action when signed out", async () => {
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
const image = screen.getByRole("img", { name: "Guest" })
expect(image).toHaveAttribute("src", guest)
expect(screen.getByText("Guest")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
})
it("treats a session payload without user as signed out", async () => {
sessionState.data = { session: { id: "session-1" } }
const { UserAccount } = await import("../user-account")
expect(() => render(<UserAccount />)).not.toThrow()
const image = screen.getByRole("img", { name: "Guest" })
expect(image).toHaveAttribute("src", guest)
expect(screen.getByText("Guest")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
})
it("shows the user's avatar and name when signed in with an image", async () => {
sessionState.data = {
user: {
name: "John Doe",
image: "https://cdn.example.com/john.png",
},
}
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
expect(screen.getByRole("img", { name: "John Doe" })).toHaveAttribute("src", "https://cdn.example.com/john.png")
expect(screen.getByText("John Doe")).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
})
it("uses initials fallback for signed-in users without an image", async () => {
sessionState.data = {
user: {
name: "John Doe",
image: null,
},
}
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
expect(screen.queryByRole("img", { name: "John Doe" })).not.toBeInTheDocument()
expect(screen.getByText("JD")).toBeInTheDocument()
expect(screen.getByText("John Doe")).toBeInTheDocument()
})
it("keeps the loading state without showing login", async () => {
sessionState.isPending = true
const { UserAccount } = await import("../user-account")
const view = render(<UserAccount />)
expect(screen.getByText("Loading...")).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
expect(view.container.querySelector("[data-slot='avatar']")).toHaveClass("animate-pulse")
})
})

View file

@ -13,7 +13,7 @@ export function APIConfigWarning({ className }: { className?: string }) {
{i18n.t("noAPIKeyConfig.warningWithLink.youMust")}
{" "}
<a
href="https://readfrog.app/docs/api-key"
href="https://readfrog.app/tutorial/api-key"
target="_blank"
rel="noreferrer"
className="underline"

View file

@ -16,10 +16,10 @@ import {
SheetTrigger,
} from "@/components/ui/base-ui/sheet"
import { QuickInsertableTextarea } from "@/components/ui/insertable-textarea"
import { DEFAULT_TRANSLATE_PROMPT_ID } from "@/utils/constants/prompt"
import { DEFAULT_TRANSLATE_PROMPT_ID, getTokenCellText, TOKENS } from "@/utils/constants/prompt"
import { getRandomUUID } from "@/utils/crypto-polyfill"
import { cn } from "@/utils/styles/utils"
import { usePromptAtoms, usePromptInsertCells } from "./context"
import { usePromptAtoms } from "./context"
export function ConfigurePrompt({
originPrompt,
@ -30,7 +30,6 @@ export function ConfigurePrompt({
className?: string
} & React.ComponentProps<"button">) {
const promptAtoms = usePromptAtoms()
const insertCells = usePromptInsertCells()
const [config, setConfig] = useAtom(promptAtoms.config)
const isExportMode = useAtomValue(promptAtoms.exportMode)
@ -119,7 +118,10 @@ export function ConfigurePrompt({
className="min-h-40 max-h-80"
disabled={isDefault}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt({ ...prompt, systemPrompt: e.target.value })}
insertCells={insertCells}
insertCells={TOKENS.map(token => ({
text: getTokenCellText(token),
description: i18n.t(`options.translation.personalizedPrompts.editPrompt.promptCellInput.${token}`),
}))}
/>
</Field>
<Field>
@ -129,7 +131,10 @@ export function ConfigurePrompt({
className="max-h-60"
disabled={isDefault}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt({ ...prompt, prompt: e.target.value })}
insertCells={insertCells}
insertCells={TOKENS.map(token => ({
text: getTokenCellText(token),
description: i18n.t(`options.translation.personalizedPrompts.editPrompt.promptCellInput.${token}`),
}))}
/>
</Field>
</FieldGroup>

View file

@ -4,10 +4,6 @@ import type { customPromptsConfigSchema } from "@/types/config/translate"
import { createContext, use } from "react"
export type CustomPromptsConfig = z.infer<typeof customPromptsConfigSchema>
export interface PromptInsertCell {
text: string
description: string
}
export interface PromptAtoms {
config: WritableAtom<CustomPromptsConfig, [CustomPromptsConfig], void>
@ -15,25 +11,12 @@ export interface PromptAtoms {
selectedPrompts: PrimitiveAtom<string[]>
}
export interface PromptConfiguratorContextValue {
promptAtoms: PromptAtoms
insertCells: PromptInsertCell[]
}
export const PromptConfiguratorContext = createContext<PromptConfiguratorContextValue | null>(null)
export const PromptConfiguratorContext = createContext<PromptAtoms | null>(null)
export function usePromptAtoms() {
const promptConfigurator = use(PromptConfiguratorContext)
if (!promptConfigurator) {
const promptAtoms = use(PromptConfiguratorContext)
if (!promptAtoms) {
throw new Error("usePromptAtoms must be used within PromptConfigurator")
}
return promptConfigurator.promptAtoms
}
export function usePromptInsertCells() {
const promptConfigurator = use(PromptConfiguratorContext)
if (!promptConfigurator) {
throw new Error("usePromptInsertCells must be used within PromptConfigurator")
}
return promptConfigurator.insertCells
return promptAtoms
}

View file

@ -1,4 +1,4 @@
import type { PromptAtoms, PromptInsertCell } from "./context"
import type { PromptAtoms } from "./context"
import { ConfigCard } from "@/entrypoints/options/components/config-card"
import { PromptConfiguratorContext } from "./context"
import { PromptList } from "./prompt-list"
@ -9,14 +9,13 @@ export { usePromptAtoms } from "./context"
interface PromptConfiguratorProps {
id?: string
promptAtoms: PromptAtoms
insertCells: PromptInsertCell[]
title: string
description: React.ReactNode
}
export function PromptConfigurator({ id, promptAtoms, insertCells, title, description }: PromptConfiguratorProps) {
export function PromptConfigurator({ id, promptAtoms, title, description }: PromptConfiguratorProps) {
return (
<PromptConfiguratorContext value={{ promptAtoms, insertCells }}>
<PromptConfiguratorContext value={promptAtoms}>
<ConfigCard id={id} className="lg:flex-col" title={title} description={description}>
<PromptList />
</ConfigCard>

View file

@ -33,7 +33,7 @@ export function PromptGrid({
const isExportMode = useAtomValue(promptAtoms.exportMode)
const patterns = config.patterns
const checkboxBaseId = useId()
const idPrefix = useId()
// Construct virtual default prompt object from code constant
const defaultPrompt: TranslatePromptObj = {
@ -90,7 +90,7 @@ export function PromptGrid({
{/* Checkbox: only show in export mode for custom prompts (not default) */}
<Activity mode={isExportMode && !isDefault ? "visible" : "hidden"}>
<Checkbox
id={`${checkboxBaseId}-check-${pattern.id}`}
id={`${idPrefix}-check-${pattern.id}`}
checked={selectedPrompts.includes(pattern.id)}
onClick={e => e.stopPropagation()}
onCheckedChange={(checked) => {
@ -103,7 +103,7 @@ export function PromptGrid({
/>
</Activity>
<Label
htmlFor={`${checkboxBaseId}-check-${pattern.id}`}
htmlFor={`${idPrefix}-check-${pattern.id}`}
className="flex-1 min-w-0 block truncate cursor-pointer"
title={pattern.name}
>

View file

@ -15,11 +15,9 @@ export const ThemeContext = createContext<ThemeContextI | undefined>(undefined)
export function ThemeProvider({
children,
container,
forcedTheme,
}: {
children: React.ReactNode
container?: HTMLElement
forcedTheme?: Theme
}) {
const [themeMode, setThemeMode] = useAtom(themeModeAtom)
@ -36,12 +34,10 @@ export function ThemeProvider({
() => !!window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches,
)
const resolvedTheme: Theme = themeMode === "system"
const theme: Theme = themeMode === "system"
? (prefersDark ? "dark" : "light")
: themeMode
const theme: Theme = forcedTheme ?? resolvedTheme
// Apply theme to document or shadow root container
useLayoutEffect(() => {
const target = container ?? document.documentElement

View file

@ -87,7 +87,7 @@ export function ShortcutKeyRecorder(
return
}
if (isModifierKey(event.key)) {
if (isModifierKey(event)) {
return
}

View file

@ -1,31 +0,0 @@
import { readFile } from "node:fs/promises"
import { describe, expect, it } from "vitest"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "../popup-animation-classes"
async function readSource(relativePath: string) {
return await readFile(new URL(relativePath, import.meta.url), "utf8")
}
describe("shared popup animation classes", () => {
it("exports the shared closed-state contract", () => {
expect(SHARED_POPUP_CLOSED_STATE_CLASS).toBe("data-closed:pointer-events-none data-closed:[animation-fill-mode:forwards]")
})
it("is applied to popup-like base-ui primitives", async () => {
const files = await Promise.all([
readSource("../select.tsx"),
readSource("../combobox.tsx"),
readSource("../popover.tsx"),
readSource("../dropdown-menu.tsx"),
readSource("../tooltip.tsx"),
readSource("../dialog.tsx"),
readSource("../alert-dialog.tsx"),
readSource("../hover-card.tsx"),
readSource("../sheet.tsx"),
])
for (const source of files) {
expect(source).toContain("SHARED_POPUP_CLOSED_STATE_CLASS")
}
})
})

View file

@ -4,7 +4,6 @@ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog
import * as React from "react"
import { Button } from "@/components/ui/base-ui/button"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
@ -32,7 +31,6 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
SHARED_POPUP_CLOSED_STATE_CLASS,
className,
)}
{...props}
@ -58,7 +56,6 @@ function AlertDialogContent({
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
SHARED_POPUP_CLOSED_STATE_CLASS,
className,
)}
{...props}

View file

@ -1,107 +0,0 @@
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import * as React from "react"
import { cn } from "@/utils/styles/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className,
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
)
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
}

View file

@ -5,35 +5,27 @@ import { cva } from "class-variance-authority"
import { cn } from "@/utils/styles/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
"default": "bg-primary text-primary-foreground hover:bg-primary/80",
"outline":
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"secondary":
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"ghost":
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
"ghost-secondary":
"text-secondary-foreground hover:bg-secondary aria-expanded:bg-secondary aria-expanded:text-secondary-foreground dark:hover:bg-secondary/50",
"destructive":
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
"outline": "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
"secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"ghost": "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
"ghost-secondary": "hover:bg-secondary text-secondary-foreground dark:hover:bg-secondary/50 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"destructive": "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
"link": "text-primary underline-offset-4 hover:underline",
},
size: {
"default":
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
"xs": "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
"lg": "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
"icon": "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
"default": "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
"xs": "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
"lg": "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
"icon": "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {

View file

@ -9,7 +9,6 @@ import {
InputGroupButton,
InputGroupInput,
} from "@/components/ui/base-ui/input-group"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
const Combobox = ComboboxPrimitive.Root
@ -117,7 +116,7 @@ function ComboboxContent({
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)", SHARED_POPUP_CLOSED_STATE_CLASS, className)}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)", className)}
{...props}
/>
</ComboboxPrimitive.Positioner>

View file

@ -5,7 +5,6 @@ import { IconX } from "@tabler/icons-react"
import * as React from "react"
import { Button } from "@/components/ui/base-ui/button"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
@ -31,7 +30,7 @@ function DialogOverlay({
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", SHARED_POPUP_CLOSED_STATE_CLASS, className)}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...props}
/>
)
@ -55,7 +54,6 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
SHARED_POPUP_CLOSED_STATE_CLASS,
className,
)}
{...props}

View file

@ -4,7 +4,6 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { IconCheck, IconChevronRight } from "@tabler/icons-react"
import * as React from "react"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
@ -45,7 +44,7 @@ function DropdownMenuContent({
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", SHARED_POPUP_CLOSED_STATE_CLASS, className)}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", className)}
{...props}
/>
</MenuPrimitive.Positioner>

View file

@ -1,6 +1,5 @@
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
@ -40,7 +39,6 @@ function HoverCardContent({
data-slot="hover-card-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-4 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 origin-(--transform-origin) outline-hidden",
SHARED_POPUP_CLOSED_STATE_CLASS,
className,
)}
{...props}

View file

@ -3,7 +3,6 @@
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import * as React from "react"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
@ -45,7 +44,6 @@ function PopoverContent({
data-slot="popover-content"
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
SHARED_POPUP_CLOSED_STATE_CLASS,
className,
)}
{...props}

View file

@ -1 +0,0 @@
export const SHARED_POPUP_CLOSED_STATE_CLASS = "data-closed:pointer-events-none data-closed:[animation-fill-mode:forwards]"

View file

@ -1,64 +1,11 @@
import type { VariantProps } from "class-variance-authority"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"
import { cva } from "class-variance-authority"
import * as React from "react"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
const Select = SelectPrimitive.Root
const selectTriggerVariants = cva(
"gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "shadow-xs border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:ring-3 aria-invalid:ring-3 [&_[data-slot=select-icon]]:text-muted-foreground",
dark: "border-white/10 bg-white/6 text-white/92 hover:bg-white/10 focus-visible:border-white/18 focus-visible:ring-white/18 focus-visible:ring-1 [&_[data-slot=select-icon]]:text-white/62",
},
size: {
default: "h-8",
sm: "h-7 rounded-[min(var(--radius-md),10px)]",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
const selectContentVariants = cva(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-36 rounded-lg shadow-md duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none",
{
variants: {
variant: {
default: "bg-popover text-popover-foreground ring-foreground/10 ring-1",
dark: "bg-[rgba(24,26,30,0.96)] text-white/90 ring-white/10 ring-1 backdrop-blur-xl",
},
},
defaultVariants: {
variant: "default",
},
},
)
const selectItemVariants = cva(
"gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
dark: "focus:bg-white/10 focus:text-white",
},
},
defaultVariants: {
variant: "default",
},
},
)
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
@ -81,22 +28,26 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
function SelectTrigger({
className,
variant = "default",
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & VariantProps<typeof selectTriggerVariants>) {
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(selectTriggerVariants({ variant, size, className }))}
data-size={size}
className={cn(
"shadow-xs border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
data-slot="select-icon"
render={
<IconChevronDown className="size-4 pointer-events-none" />
<IconChevronDown className="text-muted-foreground size-4 pointer-events-none" />
}
/>
</SelectPrimitive.Trigger>
@ -106,7 +57,6 @@ function SelectTrigger({
function SelectContent({
container,
className,
variant = "default",
children,
positionerClassName,
side = "bottom",
@ -117,7 +67,6 @@ function SelectContent({
...props
}: SelectPrimitive.Popup.Props
& Pick<SelectPrimitive.Portal.Props, "container">
& VariantProps<typeof selectContentVariants>
& {
positionerClassName?: string
}
@ -133,12 +82,12 @@ function SelectContent({
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className={cn("pointer-events-auto isolate z-50", positionerClassName)}
className={cn("isolate z-50", positionerClassName)}
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(selectContentVariants({ variant }), SHARED_POPUP_CLOSED_STATE_CLASS, className)}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", className)}
{...props}
>
<SelectScrollUpButton />
@ -165,14 +114,16 @@ function SelectLabel({
function SelectItem({
className,
variant = "default",
children,
...props
}: SelectPrimitive.Item.Props & VariantProps<typeof selectItemVariants>) {
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(selectItemVariants({ variant, className }))}
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">

View file

@ -5,7 +5,6 @@ import { IconX } from "@tabler/icons-react"
import * as React from "react"
import { Button } from "@/components/ui/base-ui/button"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
@ -28,7 +27,7 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", SHARED_POPUP_CLOSED_STATE_CLASS, className)}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...props}
/>
)
@ -53,7 +52,7 @@ function SheetContent({
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn("bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", SHARED_POPUP_CLOSED_STATE_CLASS, className)}
className={cn("bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className)}
{...props}
>
{children}

View file

@ -70,7 +70,7 @@ function SidebarProvider({
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
// eslint-disable-next-line react/use-state
// eslint-disable-next-line react-naming-convention/use-state
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(

View file

@ -1,6 +1,5 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
function TooltipProvider({
@ -56,7 +55,6 @@ function TooltipContent({
data-slot="tooltip-content"
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background z-50 w-fit max-w-xs origin-(--transform-origin)",
SHARED_POPUP_CLOSED_STATE_CLASS,
className,
)}
{...props}

View file

@ -201,14 +201,6 @@ function renderPopover({
title = "Test Popover",
onOpenChange = onOpenChangeSpy,
triggerRect = buildTriggerRect(),
contentProps,
}: {
customTrigger?: boolean
triggerLabel?: string
title?: string
onOpenChange?: typeof onOpenChangeSpy
triggerRect?: DOMRect
contentProps?: Partial<React.ComponentProps<typeof SelectionPopover.Content>>
} = {}) {
render(
<SelectionPopover.Root onOpenChange={onOpenChange}>
@ -217,7 +209,7 @@ function renderPopover({
>
{triggerLabel}
</SelectionPopover.Trigger>
<SelectionPopover.Content {...contentProps}>
<SelectionPopover.Content>
<SelectionPopover.Header className="border-b">
<SelectionPopover.Title>{title}</SelectionPopover.Title>
<div className="flex items-center gap-1">
@ -430,13 +422,6 @@ describe("selectionPopover", () => {
})
})
it("applies configured opacity on the popover surface instead of the viewport host", () => {
const { element } = renderPopover()
expect(element.parentElement?.style.opacity).toBe("")
expect(element.style.opacity).toBe("var(--rf-selection-opacity, 1)")
})
it("keeps the body shrinkable so overflow can scroll after viewport changes", () => {
const { element } = renderPopover()
@ -570,42 +555,6 @@ describe("selectionPopover", () => {
expect(screen.getByRole("button", { name: "Pin popover" })).toHaveAttribute("aria-pressed", "false")
})
it("restores focus to the trigger by default when closing", async () => {
const { trigger } = renderPopover()
const closeButton = screen.getByRole("button", { name: "Close" })
closeButton.focus()
expect(closeButton).toHaveFocus()
fireEvent.click(closeButton)
await act(async () => {
await Promise.resolve()
})
expect(trigger).toHaveFocus()
})
it("supports opting out of trigger focus restoration on close", async () => {
const { trigger } = renderPopover({
contentProps: {
finalFocus: false,
},
})
const closeButton = screen.getByRole("button", { name: "Close" })
closeButton.focus()
expect(closeButton).toHaveFocus()
fireEvent.click(closeButton)
await act(async () => {
await Promise.resolve()
})
expect(document.activeElement).not.toBe(trigger)
})
it("closes a pinned popover when another popover opens", () => {
const { firstOnOpenChange, secondOnOpenChange, firstTrigger, secondTrigger } = renderTwoPopovers()

View file

@ -290,7 +290,6 @@ function SelectionPopoverShell({
style={{
display: "flex",
...style,
opacity: "var(--rf-selection-opacity, 1)",
maxWidth: "100vw",
maxHeight: "100vh",
}}
@ -317,12 +316,10 @@ function SelectionPopoverContent({
className,
children,
container,
finalFocus,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div"> & {
container?: SelectionPopoverPortalContainer
finalFocus?: DialogPrimitive.Popup.Props["finalFocus"]
}) {
const { open, setOpen, anchor, triggerElement } = useSelectionPopoverRootContext()
const bodyElementRef = React.useRef<HTMLDivElement | null>(null)
@ -391,8 +388,6 @@ function SelectionPopoverContent({
return null
}
const resolvedFinalFocus = finalFocus ?? (triggerElement ? { current: triggerElement } : false)
return (
<DialogPrimitive.Portal container={container}>
<DialogPrimitive.Popup
@ -400,7 +395,7 @@ function SelectionPopoverContent({
"fixed inset-0 pointer-events-none focus:outline-none",
SELECTION_CONTENT_OVERLAY_LAYERS.popover,
)}
finalFocus={resolvedFinalFocus}
finalFocus={triggerElement ? { current: triggerElement } : false}
>
<SelectionPopoverShell
rndRef={rndRef}

View file

@ -232,7 +232,7 @@ export function useSelectionPopoverLayout({
}
suppressResizeObserverRef.current = false
isDraggingRef.current = false
// eslint-disable-next-line react/set-state-in-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setPosition(null)
setDragging(false)
}, [])

View file

@ -118,13 +118,8 @@ function TreeItemLabel<T = any>({ item: propItem, children, className, ...props
const { currentItem, toggleIconType } = useTreeContext<T>()
const item = propItem || currentItem
React.useEffect(() => {
if (!item) {
console.warn("TreeItemLabel: No item provided via props or context")
}
}, [item])
if (!item) {
console.warn("TreeItemLabel: No item provided via props or context")
return null
}
@ -159,13 +154,8 @@ function TreeItemLabel<T = any>({ item: propItem, children, className, ...props
function TreeDragLine({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { tree } = useTreeContext()
React.useEffect(() => {
if (!tree || typeof tree.getDragLineStyle !== "function") {
console.warn("TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method")
}
}, [tree])
if (!tree || typeof tree.getDragLineStyle !== "function") {
console.warn("TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method")
return null
}

View file

@ -1,43 +1,25 @@
import guest from "@/assets/icons/avatars/guest.svg"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/base-ui/avatar"
import { Button } from "@/components/ui/base-ui/button"
import { env } from "@/env"
import { authClient } from "@/utils/auth/auth-client"
import { WEBSITE_URL } from "@/utils/constants/url"
import { cn } from "@/utils/styles/utils"
function getUserInitials(name: string | null | undefined) {
const normalizedName = name?.trim()
if (!normalizedName)
return "U"
const parts = normalizedName.split(/\s+/)
const initials = parts.length > 1
? `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`
: Array.from(normalizedName).slice(0, 2).join("")
return initials.toUpperCase()
}
export function UserAccount() {
const { data, isPending } = authClient.useSession()
const user = data?.user
const displayName = user?.name?.trim() || "Guest"
const avatarSrc = user ? user.image : guest
const fallbackText = user ? getUserInitials(user.name) : "G"
return (
<div className="flex items-center gap-2">
<Avatar size="sm" className={cn(isPending && "animate-pulse")}>
<AvatarImage src={avatarSrc || ""} alt={displayName} />
<AvatarFallback>{fallbackText}</AvatarFallback>
</Avatar>
{isPending ? "Loading..." : displayName}
{!isPending && !user && (
<img
src={data?.user.image ?? guest}
alt="User"
className={cn("rounded-full border size-6", !data?.user.image && "p-1", isPending && "animate-pulse")}
/>
{isPending ? "Loading..." : data?.user.name ?? "Guest"}
{!isPending && !data && (
<Button
size="xs"
variant="outline"
onClick={() =>
window.open(`${env.WXT_WEBSITE_URL}/log-in`, "_blank")}
window.open(`${WEBSITE_URL}/log-in`, "_blank")}
>
Log in
</Button>

View file

@ -4,24 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
const streamTextMock = vi.fn()
const outputObjectMock = vi.fn((params: Record<string, unknown>) => params)
const getModelByIdMock = vi.fn()
const loggerErrorMock = vi.fn()
const parsePartialJsonMock = vi.fn(async (text: string | undefined) => {
if (!text) {
return { state: "undefined-input", value: undefined }
}
try {
return { state: "successful-parse", value: JSON.parse(text) }
}
catch {
try {
return { state: "repaired-parse", value: JSON.parse(`${text}}`) }
}
catch {
return { state: "failed-parse", value: undefined }
}
}
})
class MockNoOutputGeneratedError extends Error {
static isInstance(error: unknown): error is MockNoOutputGeneratedError {
@ -31,7 +13,6 @@ class MockNoOutputGeneratedError extends Error {
vi.mock("ai", () => ({
streamText: streamTextMock,
parsePartialJson: parsePartialJsonMock,
NoOutputGeneratedError: MockNoOutputGeneratedError,
Output: {
object: outputObjectMock,
@ -44,7 +25,7 @@ vi.mock("@/utils/providers/model", () => ({
vi.mock("@/utils/logger", () => ({
logger: {
error: loggerErrorMock,
error: vi.fn(),
},
}))
@ -106,16 +87,15 @@ describe("background-stream", () => {
it("streams structured object output from background", async () => {
getModelByIdMock.mockResolvedValue("mock-model")
streamTextMock.mockReturnValue({
fullStream: (async function* () {
yield { type: "text-delta", text: "{\"score\":97" }
yield { type: "text-delta", text: ",\"summary\":\"Strong argument structure\"}" }
partialOutputStream: (async function* () {
yield { score: 97 }
yield { score: 97, summary: "Strong argument structure" }
})(),
get output() {
throw new Error("structured stream should not consume output separately")
},
get partialOutputStream() {
throw new Error("structured stream should not consume partialOutputStream separately")
},
output: Promise.resolve({
score: 97,
summary: "Strong argument structure",
}),
fullStream: (async function* () {})(),
})
const chunkSnapshots: BackgroundStructuredObjectStreamSnapshot[] = []
@ -257,9 +237,7 @@ describe("background-stream", () => {
options.onError?.({ error: rootCause })
return {
fullStream: (async function* () {})(),
get output() {
throw new Error("text stream should not consume output separately")
},
output: Promise.reject(new MockNoOutputGeneratedError("No output generated. Check the stream for errors.")),
}
})
@ -316,50 +294,6 @@ describe("background-stream", () => {
expect(mockPort.disconnect).toHaveBeenCalledTimes(1)
})
it("treats stream port disconnect aborts as expected cancellation", async () => {
getModelByIdMock.mockResolvedValue("mock-model")
let streamSignal: AbortSignal | undefined
streamTextMock.mockImplementation((options: { abortSignal?: AbortSignal }) => {
streamSignal = options.abortSignal
return {
fullStream: (async function* () {
await new Promise<void>((_resolve, reject) => {
options.abortSignal?.addEventListener("abort", () => {
reject(options.abortSignal?.reason ?? new DOMException("aborted", "AbortError"))
})
})
})(),
output: new Promise<string>(() => {}),
}
})
const { handleStreamTextPort } = await import("../background-stream")
const mockPort = createMockPort("stream-text")
handleStreamTextPort(mockPort.port as never)
const startPromise = mockPort.emitMessage({
type: "start",
requestId: "req-text-abort",
payload: {
providerId: "openai-default",
prompt: "Say hello",
},
})
await new Promise(resolve => setTimeout(resolve, 0))
expect(streamTextMock).toHaveBeenCalledTimes(1)
mockPort.emitDisconnect()
await startPromise
expect(streamSignal?.aborted).toBe(true)
expect(loggerErrorMock).not.toHaveBeenCalled()
expect(mockPort.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({
type: "error",
}))
})
it("returns error for invalid text start payload and disconnects", async () => {
const { handleStreamTextPort } = await import("../background-stream")
const mockPort = createMockPort("stream-text")

View file

@ -1,279 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { createSidePanelWindowState, createToggleSidePanelHandler, getSidePanelApi, setupSidePanelMessageHandler } from "../side-panel"
function createLogger() {
return {
error: vi.fn(),
warn: vi.fn(),
}
}
const senderWindowMessage = {
sender: {
tab: {
id: 123,
windowId: 456,
},
},
}
function chromiumSidePanel<TApi>(api: TApi) {
return {
kind: "chromium-side-panel" as const,
api,
}
}
function firefoxSidebarAction<TApi>(api: TApi) {
return {
kind: "firefox-sidebar-action" as const,
api,
}
}
describe("background side panel", () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it("opens the global side panel synchronously so Chrome keeps the user gesture", async () => {
const logger = createLogger()
const calls: string[] = []
const sidePanel = {
setOptions: vi.fn(() => {
calls.push("setOptions")
}),
open: vi.fn((_options: { windowId: number }) => {
calls.push("open")
return Promise.resolve()
}),
}
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
})
const result = handler(senderWindowMessage)
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
expect(sidePanel.open.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
expect(sidePanel.setOptions).not.toHaveBeenCalled()
expect(calls).toEqual(["open"])
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
})
it("closes the global side panel when the sender window is already open", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const calls: string[] = []
const sidePanel = {
close: vi.fn((_options: { windowId: number }) => {
calls.push("close")
return Promise.resolve()
}),
open: vi.fn((_options: { windowId: number }) => {
calls.push("open")
return Promise.resolve()
}),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
expect(sidePanel.close.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
expect(sidePanel.open).not.toHaveBeenCalled()
expect(calls).toEqual(["close"])
expect(windowState.isOpen(456)).toBe(false)
})
it("tracks browser side panel open and close events for toggle state", async () => {
const logger = createLogger()
const onOpenedListeners: Array<(info: { windowId?: number }) => void> = []
const onClosedListeners: Array<(info: { windowId?: number }) => void> = []
const registeredMessageHandlers = new Map<string, (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>>()
const sidePanel = {
close: vi.fn().mockResolvedValue(undefined),
open: vi.fn().mockResolvedValue(undefined),
onClosed: {
addListener: vi.fn((listener) => {
onClosedListeners.push(listener)
}),
},
onOpened: {
addListener: vi.fn((listener) => {
onOpenedListeners.push(listener)
}),
},
}
setupSidePanelMessageHandler({
extensionBrowser: { sidePanel } as any,
logger,
registerMessageHandler: ((type: string, handler: (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>) => {
registeredMessageHandlers.set(type, handler)
}) as any,
})
onOpenedListeners[0]?.({ windowId: 456 })
const handler = registeredMessageHandlers.get("toggleSidePanel")
if (!handler) {
throw new Error("toggleSidePanel handler was not registered")
}
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
onClosedListeners[0]?.({ windowId: 456 })
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
})
it("returns an unsupported result when closing is unavailable", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const sidePanel = {
open: vi.fn(),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
expect(logger.warn).toHaveBeenCalledWith("Side panel close API is unavailable in this browser")
expect(sidePanel.open).not.toHaveBeenCalled()
})
it("clears stale open state when Chrome rejects close", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const error = new Error("No active global side panel")
const sidePanel = {
close: vi.fn().mockRejectedValue(error),
open: vi.fn().mockResolvedValue(undefined),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
expect(logger.error).toHaveBeenCalledWith("Failed to close side panel", error)
expect(windowState.isOpen(456)).toBe(false)
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
})
it("returns an unsupported result when the side panel API is unavailable", async () => {
const logger = createLogger()
const handler = createToggleSidePanelHandler({
getApi: () => null,
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
expect(logger.warn).toHaveBeenCalledWith("Side panel API is unavailable in this browser")
})
it("does not open the Firefox sidebar from a content-script message", async () => {
const logger = createLogger()
const sidebarAction = {
open: vi.fn(),
}
const handler = createToggleSidePanelHandler({
getApi: () => firefoxSidebarAction(sidebarAction),
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({
ok: false,
reason: "requires-extension-user-action",
})
expect(sidebarAction.open).not.toHaveBeenCalled()
expect(logger.warn).toHaveBeenCalledWith("Firefox sidebar requires an extension user action")
})
it("opens the Firefox sidebar when called from an extension user action", async () => {
const logger = createLogger()
const sidebarAction = {
open: vi.fn().mockResolvedValue(undefined),
}
const handler = createToggleSidePanelHandler({
getApi: () => firefoxSidebarAction(sidebarAction),
logger,
})
const result = handler({ data: { source: "extension-user-action" } })
expect(sidebarAction.open).toHaveBeenCalled()
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
})
it("returns a missing-window result when the sender window id is unavailable", async () => {
const logger = createLogger()
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel({ open: vi.fn() }),
logger,
})
await expect(handler({ sender: { tab: { id: 123 } } })).resolves.toEqual({ ok: false, reason: "missing-window" })
expect(logger.warn).toHaveBeenCalledWith(
"Cannot toggle side panel without a sender window",
{ sender: { tab: { id: 123 } } },
)
})
it("returns a toggle-failed result when Chrome rejects the open request", async () => {
const logger = createLogger()
const error = new Error("sidePanel.open() may only be called in response to a user gesture")
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel({
open: vi.fn().mockRejectedValue(error),
}),
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
expect(logger.error).toHaveBeenCalledWith("Failed to open side panel", error)
})
it("finds the Chrome sidePanel API when the WXT browser wrapper does not expose it", () => {
const sidePanel = {
open: vi.fn(),
}
vi.stubGlobal("chrome", {
sidePanel,
})
expect(getSidePanelApi({} as any)).toEqual({
kind: "chromium-side-panel",
api: sidePanel,
})
})
it("finds the Firefox sidebarAction API from the WXT browser wrapper", () => {
const sidebarAction = {
open: vi.fn(),
}
expect(getSidePanelApi({ sidebarAction } as any)).toEqual({
kind: "firefox-sidebar-action",
api: sidebarAction,
})
})
})

View file

@ -1,99 +1,26 @@
import type { ProviderConfig } from "@/types/config/provider"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/utils/constants/config"
const onMessageMock = vi.fn()
const ensureInitializedConfigMock = vi.fn()
const executeTranslateMock = vi.fn()
const generateArticleSummaryMock = vi.fn()
const putBatchRequestRecordMock = vi.fn()
const articleSummaryCacheGetMock = vi.fn()
const articleSummaryCachePutMock = vi.fn()
const translationCacheGetMock = vi.fn()
const translationCachePutMock = vi.fn()
vi.mock("@/utils/message", () => ({
onMessage: onMessageMock,
}))
vi.mock("../config", () => ({
ensureInitializedConfig: ensureInitializedConfigMock,
}))
vi.mock("@/utils/host/translate/execute-translate", () => ({
executeTranslate: executeTranslateMock,
}))
vi.mock("@/utils/content/summary", () => ({
generateArticleSummary: generateArticleSummaryMock,
}))
vi.mock("@/utils/batch-request-record", () => ({
putBatchRequestRecord: putBatchRequestRecordMock,
executeTranslate: vi.fn(),
}))
vi.mock("@/utils/db/dexie/db", () => ({
db: {
articleSummaryCache: {
get: articleSummaryCacheGetMock,
put: articleSummaryCachePutMock,
get: vi.fn(),
put: vi.fn(),
},
translationCache: {
get: translationCacheGetMock,
put: translationCachePutMock,
get: vi.fn(),
put: vi.fn(),
},
},
}))
function getRegisteredMessageHandler(name: string) {
const registration = onMessageMock.mock.calls.find(call => call[0] === name)
if (!registration) {
throw new Error(`Message handler not registered: ${name}`)
}
return registration[1] as (message: { data: Record<string, unknown> }) => Promise<unknown>
}
const llmProvider: ProviderConfig = {
id: "openai-default",
name: "OpenAI",
provider: "openai",
enabled: true,
apiKey: "sk-test",
model: { model: "gpt-5-mini", isCustomModel: false, customModel: null },
}
describe("translation queue helpers", () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
ensureInitializedConfigMock.mockResolvedValue({
...DEFAULT_CONFIG,
translate: {
...DEFAULT_CONFIG.translate,
enableAIContentAware: true,
},
videoSubtitles: {
...DEFAULT_CONFIG.videoSubtitles,
providerId: llmProvider.id,
requestQueueConfig: {
rate: 10,
capacity: 10,
},
batchQueueConfig: {
maxCharactersPerBatch: 1000,
maxItemsPerBatch: 1,
},
},
})
executeTranslateMock.mockResolvedValue("translated subtitle")
generateArticleSummaryMock.mockResolvedValue("Generated summary")
putBatchRequestRecordMock.mockResolvedValue(undefined)
articleSummaryCacheGetMock.mockResolvedValue(undefined)
articleSummaryCachePutMock.mockResolvedValue(undefined)
translationCacheGetMock.mockResolvedValue(undefined)
translationCachePutMock.mockResolvedValue(undefined)
})
it(
@ -117,194 +44,19 @@ describe("translation queue helpers", () => {
baseURL: "https://api.deeplx.org",
}
const llmProvider: ProviderConfig = {
id: "openai",
name: "OpenAI",
provider: "openai",
enabled: true,
apiKey: "sk-test",
model: { model: "gpt-5-mini", isCustomModel: false, customModel: null },
}
expect(shouldUseBatchQueue(deeplProvider)).toBe(false)
expect(shouldUseBatchQueue(deeplxProvider)).toBe(false)
expect(shouldUseBatchQueue(llmProvider)).toBe(true)
},
15_000,
)
it("passes subtitle summary through the translation queue without generating a new summary", async () => {
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
await setUpSubtitlesTranslationQueue()
const handler = getRegisteredMessageHandler("enqueueSubtitlesTranslateRequest")
const result = await handler({
data: {
text: "hello",
langConfig: DEFAULT_CONFIG.language,
providerConfig: llmProvider,
scheduleAt: Date.now(),
hash: "subtitle-hash",
videoTitle: "Video title",
summary: "Ready summary",
},
})
expect(result).toBe("translated subtitle")
expect(generateArticleSummaryMock).not.toHaveBeenCalled()
expect(executeTranslateMock).toHaveBeenCalledWith(
"hello",
DEFAULT_CONFIG.language,
llmProvider,
expect.any(Function),
expect.objectContaining({
isBatch: true,
context: {
videoTitle: "Video title",
videoSummary: "Ready summary",
},
}),
)
})
it("passes webpage context through the translation queue without generating a new summary", async () => {
const { setUpWebPageTranslationQueue } = await import("../translation-queues")
await setUpWebPageTranslationQueue()
const handler = getRegisteredMessageHandler("enqueueTranslateRequest")
const result = await handler({
data: {
text: "hello",
langConfig: DEFAULT_CONFIG.language,
providerConfig: llmProvider,
scheduleAt: Date.now(),
hash: "webpage-hash",
webTitle: "Page title",
webContent: "Page body",
webSummary: "Ready summary",
},
})
expect(result).toBe("translated subtitle")
expect(generateArticleSummaryMock).not.toHaveBeenCalled()
expect(executeTranslateMock).toHaveBeenCalledWith(
"hello",
DEFAULT_CONFIG.language,
llmProvider,
expect.any(Function),
expect.objectContaining({
context: {
webTitle: "Page title",
webContent: "Page body",
webSummary: "Ready summary",
},
}),
)
})
it("exposes webpage summary generation as a separate background handler", async () => {
const { setUpWebPageTranslationQueue } = await import("../translation-queues")
await setUpWebPageTranslationQueue()
const handler = getRegisteredMessageHandler("getOrGenerateWebPageSummary")
const result = await handler({
data: {
webTitle: "Page title",
webContent: "page body",
providerConfig: llmProvider,
},
})
expect(result).toBe("Generated summary")
expect(generateArticleSummaryMock).toHaveBeenCalledWith(
"Page title",
"page body",
llmProvider,
)
})
it("exposes subtitle summary generation as a separate background handler", async () => {
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
await setUpSubtitlesTranslationQueue()
const handler = getRegisteredMessageHandler("getSubtitlesSummary")
const result = await handler({
data: {
videoTitle: "Video title",
subtitlesContext: "subtitle transcript",
providerConfig: llmProvider,
},
})
expect(result).toBe("Generated summary")
expect(generateArticleSummaryMock).toHaveBeenCalledWith(
"Video title",
"subtitle transcript",
llmProvider,
)
})
it("returns null for invalid subtitle summary requests", async () => {
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
await setUpSubtitlesTranslationQueue()
const handler = getRegisteredMessageHandler("getSubtitlesSummary")
const result = await handler({
data: {
videoTitle: "",
subtitlesContext: "subtitle transcript",
providerConfig: llmProvider,
},
})
expect(result).toBeNull()
expect(generateArticleSummaryMock).not.toHaveBeenCalled()
})
it("returns null when subtitle summary generation has no result", async () => {
generateArticleSummaryMock.mockResolvedValue(null)
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
await setUpSubtitlesTranslationQueue()
const handler = getRegisteredMessageHandler("getSubtitlesSummary")
const result = await handler({
data: {
videoTitle: "Video title",
subtitlesContext: "subtitle transcript",
providerConfig: llmProvider,
},
})
expect(result).toBeNull()
})
it("deduplicates concurrent subtitle summary generation requests", async () => {
let resolveSummary!: (summary: string) => void
generateArticleSummaryMock.mockImplementation(
() => new Promise((resolve: (summary: string) => void) => {
resolveSummary = resolve
}),
)
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
await setUpSubtitlesTranslationQueue()
const handler = getRegisteredMessageHandler("getSubtitlesSummary")
const firstRequest = handler({
data: {
videoTitle: "Video title",
subtitlesContext: "subtitle transcript",
providerConfig: llmProvider,
},
})
const secondRequest = handler({
data: {
videoTitle: "Video title",
subtitlesContext: "subtitle transcript",
providerConfig: llmProvider,
},
})
await Promise.resolve()
await Promise.resolve()
resolveSummary("Generated summary")
await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual([
"Generated summary",
"Generated summary",
])
expect(generateArticleSummaryMock).toHaveBeenCalledTimes(1)
})
})

View file

@ -2,7 +2,6 @@ import type { CaptureResult } from "posthog-js/dist/module.no-external"
import type { FeatureUsedEventProperties } from "@/types/analytics"
import { storage } from "#imports"
import posthog from "posthog-js/dist/module.no-external"
import { env } from "@/env"
import {
ANALYTICS_ENABLED_STORAGE_KEY,
ANALYTICS_FEATURE_USED_EVENT,
@ -59,12 +58,12 @@ export function resolveDistinctIdOverride(
function createDefaultRuntime(): BackgroundAnalyticsRuntime {
return {
apiHost: env.WXT_POSTHOG_HOST,
apiKey: env.WXT_POSTHOG_API_KEY,
apiHost: import.meta.env.WXT_POSTHOG_HOST,
apiKey: import.meta.env.WXT_POSTHOG_API_KEY,
createDistinctId: () => getRandomUUID(),
defaultAnalyticsEnabled: DEFAULT_ANALYTICS_ENABLED,
distinctIdOverride: resolveDistinctIdOverride(
env.WXT_POSTHOG_TEST_UUID,
import.meta.env.WXT_POSTHOG_TEST_UUID,
import.meta.env.DEV,
),
extensionVersion: EXTENSION_VERSION,

View file

@ -14,7 +14,7 @@ import type {
StreamRuntimeOptions,
ThinkingSnapshot,
} from "@/types/background-stream"
import { Output, parsePartialJson, streamText } from "ai"
import { Output, streamText } from "ai"
import { z } from "zod"
import { BACKGROUND_STREAM_PORTS } from "@/types/background-stream"
import { extractAISDKErrorMessage } from "@/utils/error/extract-message"
@ -23,15 +23,6 @@ import { getModelById } from "@/utils/providers/model"
const invalidStreamStartPayloadMessage = "Invalid stream start payload"
function createStreamAbortError(message: string) {
return new DOMException(message, "AbortError")
}
function isAbortLikeError(error: unknown) {
return (error instanceof DOMException && error.name === "AbortError")
|| (error instanceof Error && error.name === "AbortError")
}
const streamPortStartEnvelopeSchema = z.object({
type: z.literal("start"),
requestId: z.string().trim().min(1),
@ -137,7 +128,7 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
}
disconnectListener = () => {
abortController.abort(createStreamAbortError("stream port disconnected"))
abortController.abort()
cleanup()
}
@ -200,12 +191,10 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
}
catch (error) {
const finalError = streamError ?? error
if (abortController.signal.aborted || isAbortLikeError(finalError)) {
return
}
logger.error("[Background] Stream Function failed", finalError)
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
if (!abortController.signal.aborted) {
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
}
}
finally {
cleanup()
@ -236,10 +225,6 @@ function createStreamSnapshot<TOutput>(
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
export async function runStreamTextInBackground(
serializablePayload: BackgroundStreamTextSerializablePayload,
options: StreamRuntimeOptions<BackgroundTextStreamSnapshot> = {},
@ -294,18 +279,16 @@ export async function runStreamTextInBackground(
onChunk?.(createStreamSnapshot(cumulativeText, thinking))
break
}
case "error": {
throw part.error
}
}
}
const finalText = await result.output
thinking = {
...thinking,
status: "complete",
}
return createStreamSnapshot(cumulativeText, thinking)
return createStreamSnapshot(finalText, thinking)
}
export async function runStructuredObjectStreamInBackground(
@ -335,60 +318,64 @@ export async function runStructuredObjectStreamInBackground(
for (const field of outputSchema) {
schemaShape[field.name] = fieldTypeToZodSchema[field.type] ?? z.string().nullable()
}
const objectSchema = z.object(schemaShape).strict()
const result = streamText({
...(streamParams as Parameters<typeof streamText>[0]),
model,
output: Output.object({
schema: objectSchema,
schema: z.object(schemaShape).strict(),
}),
abortSignal: signal,
onError: ({ error }) => {
onError?.(error)
},
})
let cumulativeText = ""
for await (const part of result.fullStream) {
if (signal?.aborted) {
throw new DOMException("stream aborted", "AbortError")
}
const consumePartialOutput = async () => {
for await (const partial of result.partialOutputStream) {
if (signal?.aborted) {
throw new DOMException("stream aborted", "AbortError")
}
switch (part.type) {
case "text-delta": {
cumulativeText += part.text
const partial = await parsePartialJson(cumulativeText)
if (isRecord(partial.value)) {
cumulativeValue = { ...cumulativeValue, ...partial.value }
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
}
break
}
case "reasoning-delta": {
thinking = {
status: "thinking",
text: thinking.text + part.text,
}
if (partial && typeof partial === "object" && !Array.isArray(partial)) {
cumulativeValue = { ...cumulativeValue, ...partial }
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "reasoning-end": {
thinking = {
...thinking,
status: "complete",
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "error": {
throw part.error
}
}
}
const finalJson = await parsePartialJson(cumulativeText)
const finalValue = objectSchema.parse(finalJson.value)
const consumeFullStream = async () => {
for await (const part of result.fullStream) {
if (signal?.aborted) {
throw new DOMException("stream aborted", "AbortError")
}
switch (part.type) {
case "reasoning-delta": {
thinking = {
status: "thinking",
text: thinking.text + part.text,
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "reasoning-end": {
thinking = {
...thinking,
status: "complete",
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
}
}
}
const [finalValue] = await Promise.all([
result.output,
consumePartialOutput(),
consumeFullStream(),
])
thinking = {
...thinking,

View file

@ -1,9 +1,8 @@
import "@/utils/zod-config"
import { browser, defineBackground } from "#imports"
import { env } from "@/env"
import { WEBSITE_URL } from "@/utils/constants/url"
import { logger } from "@/utils/logger"
import { onMessage } from "@/utils/message"
import { openOptionsPage } from "@/utils/navigation"
import { SessionCacheGroupRegistry } from "@/utils/session-cache/session-cache-group-registry"
import { runAiSegmentSubtitles } from "./ai-segmentation"
import { setupAnalyticsMessageHandlers } from "./analytics"
@ -18,7 +17,6 @@ import { setupLLMGenerateTextMessageHandlers } from "./llm-generate-text"
import { initMockData } from "./mock-data"
import { newUserGuide } from "./new-user-guide"
import { proxyFetch } from "./proxy-fetch"
import { setupSidePanelMessageHandler } from "./side-panel"
import { setUpSubtitlesTranslationQueue, setUpWebPageTranslationQueue } from "./translation-queues"
import { translationMessage } from "./translation-signal"
import { setupTTSPlaybackMessageHandlers } from "./tts-playback"
@ -35,7 +33,7 @@ export default defineBackground({
// Open tutorial page when extension is installed
if (details.reason === "install") {
await browser.tabs.create({
url: `${env.WXT_WEBSITE_URL}/guide/step-1`,
url: `${WEBSITE_URL}/guide/step-1`,
})
}
@ -52,15 +50,9 @@ export default defineBackground({
await browser.tabs.create({ url, active: active ?? true })
})
onMessage("openOptionsPage", async () => {
onMessage("openOptionsPage", () => {
logger.info("openOptionsPage")
await openOptionsPage()
})
setupSidePanelMessageHandler({
extensionBrowser: browser,
logger,
registerMessageHandler: onMessage,
void browser.runtime.openOptionsPage()
})
onMessage("aiSegmentSubtitles", async (message) => {

View file

@ -1,5 +1,5 @@
import { browser } from "#imports"
import { env } from "@/env"
import { OFFICIAL_SITE_URL_PATTERNS } from "@/utils/constants/url"
import { onMessage, sendMessage } from "@/utils/message"
let lastIsPinned = false
@ -30,7 +30,7 @@ async function checkPinnedAndNotify() {
return
lastIsPinned = isOnToolbar
browser.tabs.query({ url: env.WXT_OFFICIAL_SITE_ORIGINS.map((origin: string) => `${origin}/*`) }, (tabs) => {
browser.tabs.query({ url: OFFICIAL_SITE_URL_PATTERNS }, (tabs) => {
for (const tab of tabs) {
void sendMessage("pinStateChanged", { isPinned: isOnToolbar }, tab.id)
}

View file

@ -1,7 +1,6 @@
import type { ProxyResponse } from "@/types/proxy-fetch"
import { browser } from "#imports"
import { AUTH_COOKIE_PATTERNS } from "@read-frog/definitions"
import { env } from "@/env"
import { AUTH_COOKIE_PATTERNS, AUTH_DOMAINS } from "@read-frog/definitions"
import { DEFAULT_PROXY_CACHE_TTL_MS } from "@/utils/constants/proxy-fetch"
import { logger } from "@/utils/logger"
@ -38,7 +37,7 @@ export function proxyFetch() {
browser.cookies.onChanged.addListener(async (changeInfo) => {
const { cookie, removed } = changeInfo
// Check if it's an auth-related cookie for monitored domains
if (cookie.domain && env.WXT_AUTH_COOKIE_DOMAINS.some((domain: string) => cookie.domain.includes(domain))) {
if (cookie.domain && AUTH_DOMAINS.some(domain => cookie.domain.includes(domain))) {
// Check against defined auth cookie patterns
if (AUTH_COOKIE_PATTERNS.some(name => cookie.name.includes(name))) {
// Get current cookie value for before/after comparison

View file

@ -1,276 +0,0 @@
import type { browser } from "#imports"
import type { onMessage } from "@/utils/message"
interface ChromiumSidePanelApi {
close?: (options: { windowId: number }) => Promise<void> | void
open: (options: { windowId: number }) => Promise<void> | void
onClosed?: SidePanelEvent<SidePanelStateInfo>
onOpened?: SidePanelEvent<SidePanelStateInfo>
}
interface FirefoxSidebarActionApi {
close?: () => Promise<void> | void
open?: () => Promise<void> | void
toggle?: () => Promise<void> | void
}
type BrowserSidePanelApi
= | { kind: "chromium-side-panel", api: ChromiumSidePanelApi }
| { kind: "firefox-sidebar-action", api: FirefoxSidebarActionApi }
interface SidePanelEvent<TInfo> {
addListener: (callback: (info: TInfo) => void) => void
}
interface SidePanelStateInfo {
windowId?: number
}
interface ToggleSidePanelMessage {
data?: {
source?: "content-script" | "extension-user-action"
}
sender?: {
tab?: {
id?: number
windowId?: number
}
}
}
type ToggleSidePanelResult
= | { ok: true, action: "opened" | "closed" }
| { ok: false, reason: "missing-window" | "unsupported" | "toggle-failed" | "requires-extension-user-action" }
interface SidePanelLogger {
error: (...args: any[]) => void
warn: (...args: any[]) => void
}
export function createSidePanelWindowState() {
const activeWindowIds = new Set<number>()
return {
isOpen(windowId: number) {
return activeWindowIds.has(windowId)
},
markClosed(info: SidePanelStateInfo) {
if (typeof info.windowId === "number") {
activeWindowIds.delete(info.windowId)
}
},
markOpened(info: SidePanelStateInfo) {
if (typeof info.windowId === "number") {
activeWindowIds.add(info.windowId)
}
},
}
}
function getToggleSource(message: ToggleSidePanelMessage) {
return message.data?.source ?? "content-script"
}
function toChromiumSidePanelApi(api: Partial<ChromiumSidePanelApi> | undefined): BrowserSidePanelApi | null {
if (typeof api?.open !== "function") {
return null
}
return {
kind: "chromium-side-panel",
api: api as ChromiumSidePanelApi,
}
}
function toFirefoxSidebarActionApi(api: Partial<FirefoxSidebarActionApi> | undefined): BrowserSidePanelApi | null {
if (typeof api?.open !== "function" && typeof api?.toggle !== "function") {
return null
}
return {
kind: "firefox-sidebar-action",
api: api as FirefoxSidebarActionApi,
}
}
export function getSidePanelApi(extensionBrowser: typeof browser): BrowserSidePanelApi | null {
const browserWithSidePanel = extensionBrowser as typeof extensionBrowser & { sidePanel?: Partial<ChromiumSidePanelApi> }
if (typeof browserWithSidePanel.sidePanel?.open === "function") {
return toChromiumSidePanelApi(browserWithSidePanel.sidePanel)
}
const globalWithChrome = globalThis as typeof globalThis & {
chrome?: { sidePanel?: Partial<ChromiumSidePanelApi> }
}
if (typeof globalWithChrome.chrome?.sidePanel?.open === "function") {
return toChromiumSidePanelApi(globalWithChrome.chrome.sidePanel)
}
const browserWithSidebarAction = extensionBrowser as typeof extensionBrowser & { sidebarAction?: Partial<FirefoxSidebarActionApi> }
const sidebarAction = toFirefoxSidebarActionApi(browserWithSidebarAction.sidebarAction)
if (sidebarAction) {
return sidebarAction
}
const globalWithBrowser = globalThis as typeof globalThis & {
browser?: { sidebarAction?: Partial<FirefoxSidebarActionApi> }
}
const globalSidebarAction = toFirefoxSidebarActionApi(globalWithBrowser.browser?.sidebarAction)
if (globalSidebarAction) {
return globalSidebarAction
}
return null
}
function toggleFirefoxSidebarAction({
api,
logger,
source,
}: {
api: FirefoxSidebarActionApi
logger: SidePanelLogger
source: ReturnType<typeof getToggleSource>
}): Promise<ToggleSidePanelResult> {
if (source !== "extension-user-action") {
logger.warn("Firefox sidebar requires an extension user action")
return Promise.resolve({ ok: false, reason: "requires-extension-user-action" } as const)
}
const openSidebar = typeof api.open === "function"
? () => api.open?.()
: typeof api.toggle === "function"
? () => api.toggle?.()
: null
if (!openSidebar) {
logger.warn("Firefox sidebar open API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
try {
const openResult = openSidebar()
return Promise.resolve(openResult)
.then(() => ({ ok: true, action: "opened" } as const))
.catch((error) => {
logger.error("Failed to open Firefox sidebar", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
logger.error("Failed to open Firefox sidebar", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
export function createToggleSidePanelHandler({
getApi,
logger,
windowState = createSidePanelWindowState(),
}: {
getApi: () => BrowserSidePanelApi | null
logger: SidePanelLogger
windowState?: ReturnType<typeof createSidePanelWindowState>
}) {
return (message: ToggleSidePanelMessage): Promise<ToggleSidePanelResult> => {
const browserSidePanel = getApi()
if (!browserSidePanel) {
logger.warn("Side panel API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
if (browserSidePanel.kind === "firefox-sidebar-action") {
return toggleFirefoxSidebarAction({
api: browserSidePanel.api,
logger,
source: getToggleSource(message),
})
}
const windowId = message.sender?.tab?.windowId
if (typeof windowId !== "number") {
logger.warn("Cannot toggle side panel without a sender window", message)
return Promise.resolve({ ok: false, reason: "missing-window" } as const)
}
const sidePanel = browserSidePanel.api
if (windowState.isOpen(windowId)) {
if (typeof sidePanel.close !== "function") {
logger.warn("Side panel close API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
try {
const closeResult = sidePanel.close({ windowId })
return Promise.resolve(closeResult)
.then(() => {
windowState.markClosed({ windowId })
return { ok: true, action: "closed" } as const
})
.catch((error) => {
windowState.markClosed({ windowId })
logger.error("Failed to close side panel", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
windowState.markClosed({ windowId })
logger.error("Failed to close side panel", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
try {
// Chrome requires sidePanel.open() to run directly in the user-gesture
// task. Do not await other async APIs before this call.
const openResult = sidePanel.open({ windowId })
return Promise.resolve(openResult)
.then(() => {
windowState.markOpened({ windowId })
return { ok: true, action: "opened" } as const
})
.catch((error) => {
logger.error("Failed to open side panel", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
logger.error("Failed to open side panel", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
}
export function setupSidePanelMessageHandler({
extensionBrowser,
logger,
registerMessageHandler,
}: {
extensionBrowser: typeof browser
logger: SidePanelLogger
registerMessageHandler: typeof onMessage
}) {
const windowState = createSidePanelWindowState()
const sidePanel = getSidePanelApi(extensionBrowser)
if (sidePanel?.kind !== "chromium-side-panel") {
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
getApi: () => getSidePanelApi(extensionBrowser),
logger,
windowState,
}))
return
}
sidePanel.api.onOpened?.addListener((info) => {
windowState.markOpened(info)
})
sidePanel.api.onClosed?.addListener((info) => {
windowState.markClosed(info)
})
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
getApi: () => getSidePanelApi(extensionBrowser),
logger,
windowState,
}))
}

View file

@ -1,7 +1,7 @@
import type { Config } from "@/types/config/config"
import type { LLMProviderConfig, ProviderConfig } from "@/types/config/provider"
import type { BatchQueueConfig, RequestQueueConfig } from "@/types/config/translate"
import type { SubtitlePromptContext, WebPagePromptContext } from "@/types/content"
import type { ArticleContent } from "@/types/content"
import type { PromptResolver } from "@/utils/host/translate/api/ai"
import { isLLMProviderConfig } from "@/types/config/provider"
import { putBatchRequestRecord } from "@/utils/batch-request-record"
@ -12,7 +12,6 @@ import { cleanText } from "@/utils/content/utils"
import { db } from "@/utils/db/dexie/db"
import { Sha256Hex } from "@/utils/hash"
import { executeTranslate } from "@/utils/host/translate/execute-translate"
import { normalizePromptContextValue } from "@/utils/host/translate/translate-text"
import { logger } from "@/utils/logger"
import { onMessage } from "@/utils/message"
import { getSubtitlesTranslatePrompt } from "@/utils/prompts/subtitles"
@ -29,78 +28,27 @@ export function shouldUseBatchQueue(providerConfig: ProviderConfig): boolean {
return isLLMProviderConfig(providerConfig)
}
export async function executeBatchTranslation<TContext>(
dataList: TranslateBatchData<TContext>[],
promptResolver: PromptResolver<TContext>,
export async function executeBatchTranslation(
dataList: TranslateBatchData[],
promptResolver: PromptResolver,
): Promise<string[]> {
const { langConfig, providerConfig, context } = dataList[0]
const { langConfig, providerConfig, content } = dataList[0]
const texts = dataList.map(d => d.text)
const batchText = texts.join(`\n\n${BATCH_SEPARATOR}\n\n`)
const result = await executeTranslate(batchText, langConfig, providerConfig, promptResolver, { isBatch: true, context })
const result = await executeTranslate(batchText, langConfig, providerConfig, promptResolver, { isBatch: true, content })
return parseBatchResult(result)
}
async function getOrGenerateWebPageSummary(
webTitle: string,
webContent: string,
async function getOrGenerateSummary(
title: string,
textContent: string,
providerConfig: LLMProviderConfig,
requestQueue: RequestQueue,
): Promise<string | null> {
const preparedText = cleanText(webContent)
): Promise<string | undefined> {
const preparedText = cleanText(textContent)
if (!preparedText) {
return null
}
const textHash = Sha256Hex(preparedText)
const cacheKey = Sha256Hex(webTitle, textHash, JSON.stringify(providerConfig))
const cached = await db.articleSummaryCache.get(cacheKey)
if (cached) {
logger.info("Using cached summary")
return cached.summary
}
const thunk = async () => {
const cachedAgain = await db.articleSummaryCache.get(cacheKey)
if (cachedAgain) {
return cachedAgain.summary
}
const summary = await generateArticleSummary(webTitle, webContent, providerConfig)
if (!summary) {
return ""
}
await db.articleSummaryCache.put({
key: cacheKey,
summary,
createdAt: new Date(),
})
logger.info("Generated and cached new summary")
return summary
}
try {
const summary = await requestQueue.enqueue(thunk, Date.now(), cacheKey)
return summary || null
}
catch (error) {
logger.warn("Failed to get/generate summary:", error)
return null
}
}
async function getOrGenerateSubtitleSummary(
videoTitle: string,
subtitlesContext: string,
providerConfig: LLMProviderConfig,
requestQueue: RequestQueue,
): Promise<string | null> {
const preparedText = cleanText(subtitlesContext)
if (!preparedText) {
return null
return undefined
}
const textHash = Sha256Hex(preparedText)
@ -118,7 +66,7 @@ async function getOrGenerateSubtitleSummary(
return cachedAgain.summary
}
const summary = await generateArticleSummary(videoTitle, subtitlesContext, providerConfig)
const summary = await generateArticleSummary(title, textContent, providerConfig)
if (!summary) {
return ""
}
@ -135,30 +83,30 @@ async function getOrGenerateSubtitleSummary(
try {
const summary = await requestQueue.enqueue(thunk, Date.now(), cacheKey)
return summary || null
return summary || undefined
}
catch (error) {
logger.warn("Failed to get/generate summary:", error)
return null
return undefined
}
}
export interface TranslateBatchData<TContext = unknown> {
export interface TranslateBatchData {
text: string
langConfig: Config["language"]
providerConfig: ProviderConfig
hash: string
scheduleAt: number
context?: TContext
content?: ArticleContent
}
interface TranslationQueueSetupConfig<TContext = unknown> {
interface TranslationQueueSetupConfig {
requestQueueConfig: RequestQueueConfig
batchQueueConfig: BatchQueueConfig
promptResolver: PromptResolver<TContext>
promptResolver: PromptResolver
}
async function createTranslationQueues<TContext>(config: TranslationQueueSetupConfig<TContext>) {
async function createTranslationQueues(config: TranslationQueueSetupConfig) {
const { rate, capacity } = config.requestQueueConfig
const { maxCharactersPerBatch, maxItemsPerBatch } = config.batchQueueConfig
const { promptResolver } = config
@ -171,7 +119,7 @@ async function createTranslationQueues<TContext>(config: TranslationQueueSetupCo
baseRetryDelayMs: 1_000,
})
const batchQueue = new BatchQueue<TranslateBatchData<TContext>, string>({
const batchQueue = new BatchQueue<TranslateBatchData, string>({
maxCharactersPerBatch,
maxItemsPerBatch,
batchDelay: 100,
@ -194,10 +142,10 @@ async function createTranslationQueues<TContext>(config: TranslationQueueSetupCo
return requestQueue.enqueue(batchThunk, earliestScheduleAt, hash)
},
executeIndividual: async (data) => {
const { text, langConfig, providerConfig, hash, scheduleAt, context } = data
const { text, langConfig, providerConfig, hash, scheduleAt, content } = data
const thunk = async () => {
await putBatchRequestRecord({ originalRequestCount: 1, providerConfig })
return executeTranslate(text, langConfig, providerConfig, promptResolver, { context })
return executeTranslate(text, langConfig, providerConfig, promptResolver, { content })
}
return requestQueue.enqueue(thunk, scheduleAt, hash)
},
@ -225,7 +173,7 @@ export async function setUpWebPageTranslationQueue() {
})
onMessage("enqueueTranslateRequest", async (message) => {
const { data: { text, langConfig, providerConfig, scheduleAt, hash, webTitle, webContent, webSummary } } = message
const { data: { text, langConfig, providerConfig, scheduleAt, hash, articleTitle, articleTextContent } } = message
// Check cache first
if (hash) {
@ -236,14 +184,23 @@ export async function setUpWebPageTranslationQueue() {
}
let result = ""
const context: WebPagePromptContext = {
webTitle: normalizePromptContextValue(webTitle),
webContent: normalizePromptContextValue(webContent),
webSummary: normalizePromptContextValue(webSummary),
const content: ArticleContent = {
title: articleTitle ?? "",
}
if (shouldUseBatchQueue(providerConfig)) {
const data = { text, langConfig, providerConfig, hash, scheduleAt, context }
// Generate or fetch cached summary if AI Content Aware is enabled
const config = await ensureInitializedConfig()
if (
isLLMProviderConfig(providerConfig)
&& config?.translate.enableAIContentAware
&& articleTitle != null
&& articleTextContent != null
) {
content.summary = await getOrGenerateSummary(articleTitle, articleTextContent, providerConfig, requestQueue)
}
const data = { text, langConfig, providerConfig, hash, scheduleAt, content }
result = await batchQueue.enqueue(data)
}
else {
@ -264,16 +221,6 @@ export async function setUpWebPageTranslationQueue() {
return result
})
onMessage("getOrGenerateWebPageSummary", async (message) => {
const { webTitle, webContent, providerConfig } = message.data
if (!isLLMProviderConfig(providerConfig) || !webTitle || !webContent) {
return null
}
return await getOrGenerateWebPageSummary(webTitle, webContent, providerConfig, requestQueue)
})
onMessage("setTranslateRequestQueueConfig", (message) => {
const { data } = message
requestQueue.setQueueOptions(data)
@ -299,7 +246,7 @@ export async function setUpSubtitlesTranslationQueue() {
})
onMessage("enqueueSubtitlesTranslateRequest", async (message) => {
const { data: { text, langConfig, providerConfig, scheduleAt, hash, videoTitle, summary } } = message
const { data: { text, langConfig, providerConfig, scheduleAt, hash, videoTitle, subtitlesContext } } = message
if (hash) {
const cached = await db.translationCache.get(hash)
@ -309,13 +256,22 @@ export async function setUpSubtitlesTranslationQueue() {
}
let result = ""
const context: SubtitlePromptContext = {
videoTitle: normalizePromptContextValue(videoTitle),
videoSummary: normalizePromptContextValue(summary),
const content: ArticleContent = {
title: videoTitle || "",
}
if (shouldUseBatchQueue(providerConfig)) {
const data = { text, langConfig, providerConfig, hash, scheduleAt, context }
const runtimeConfig = await ensureInitializedConfig()
if (
isLLMProviderConfig(providerConfig)
&& runtimeConfig?.translate.enableAIContentAware
&& videoTitle
&& subtitlesContext
) {
content.summary = await getOrGenerateSummary(videoTitle, subtitlesContext, providerConfig, requestQueue)
}
const data = { text, langConfig, providerConfig, hash, scheduleAt, content }
result = await batchQueue.enqueue(data)
}
else {
@ -334,16 +290,6 @@ export async function setUpSubtitlesTranslationQueue() {
return result
})
onMessage("getSubtitlesSummary", async (message) => {
const { videoTitle, subtitlesContext, providerConfig } = message.data
if (!isLLMProviderConfig(providerConfig) || !videoTitle || !subtitlesContext) {
return null
}
return await getOrGenerateSubtitleSummary(videoTitle, subtitlesContext, providerConfig, requestQueue)
})
onMessage("setSubtitlesRequestQueueConfig", (message) => {
const { data } = message
requestQueue.setQueueOptions(data)

View file

@ -1,14 +1,14 @@
import type { Config } from "@/types/config/config"
import { defineContentScript, storage } from "#imports"
import { kebabCase } from "case-anything"
import { env } from "@/env"
import { getLocalConfig } from "@/utils/config/storage"
import { APP_NAME } from "@/utils/constants/app"
import { CONFIG_STORAGE_KEY } from "@/utils/constants/config"
import { OFFICIAL_SITE_URL_PATTERNS } from "@/utils/constants/url"
import { onMessage, sendMessage } from "@/utils/message"
export default defineContentScript({
matches: env.WXT_OFFICIAL_SITE_ORIGINS.map((origin: string) => `${origin}/*`),
matches: OFFICIAL_SITE_URL_PATTERNS,
async main() {
onMessage("pinStateChanged", (msg) => {
window.postMessage({ source: `${kebabCase(APP_NAME)}-ext`, ...msg }, "*")

View file

@ -3,7 +3,7 @@ import type { LangCodeISO6393 } from "@read-frog/definitions"
import type { Config } from "@/types/config/config"
import { storage } from "#imports"
import { DEFAULT_CONFIG, DETECTED_CODE_STORAGE_KEY } from "@/utils/constants/config"
import { detectPageLanguageLightweight } from "@/utils/content/page-language"
import { getDocumentInfo } from "@/utils/content/analyze"
import { ensurePresetStyles } from "@/utils/host/translate/ui/style-injector"
import { logger } from "@/utils/logger"
import { onMessage, sendMessage } from "@/utils/message"
@ -55,7 +55,7 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
}
// Only the top frame should detect and set language to avoid race conditions from iframes
if (window === window.top) {
const { detectedCodeOrUnd } = await detectPageLanguageLightweight()
const { detectedCodeOrUnd } = await getDocumentInfo()
const detectedCode: LangCodeISO6393 = detectedCodeOrUnd === "und" ? "eng" : detectedCodeOrUnd
await storage.setItem<LangCodeISO6393>(`local:${DETECTED_CODE_STORAGE_KEY}`, detectedCode)
// Notify background script that URL has changed, let it decide whether to automatically enable translation
@ -92,7 +92,7 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
// Only the top frame should detect and set language to avoid race conditions from iframes
if (window === window.top) {
const { detectedCodeOrUnd } = await detectPageLanguageLightweight()
const { detectedCodeOrUnd } = await getDocumentInfo()
const initialDetectedCode: LangCodeISO6393 = detectedCodeOrUnd === "und" ? "eng" : detectedCodeOrUnd
await storage.setItem<LangCodeISO6393>(`local:${DETECTED_CODE_STORAGE_KEY}`, initialDetectedCode)

View file

@ -1,307 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/utils/constants/config"
import { PageTranslationManager } from "../page-translation"
const {
mockDeepQueryTopLevelSelector,
mockGetDetectedCodeFromStorage,
mockGetLocalConfig,
mockGetOrCreateWebPageContext,
mockHasNoWalkAncestor,
mockIsDontWalkIntoAndDontTranslateAsChildElement,
mockIsDontWalkIntoButTranslateAsChildElement,
mockRemoveAllTranslatedWrapperNodes,
mockSendMessage,
mockTranslateTextForPageTitle,
mockTranslateWalkedElement,
mockValidateTranslationConfigAndToast,
mockWalkAndLabelElement,
} = vi.hoisted(() => ({
mockGetDetectedCodeFromStorage: vi.fn(),
mockGetLocalConfig: vi.fn(),
mockGetOrCreateWebPageContext: vi.fn(),
mockDeepQueryTopLevelSelector: vi.fn(),
mockHasNoWalkAncestor: vi.fn(),
mockIsDontWalkIntoAndDontTranslateAsChildElement: vi.fn(),
mockIsDontWalkIntoButTranslateAsChildElement: vi.fn(),
mockWalkAndLabelElement: vi.fn(),
mockRemoveAllTranslatedWrapperNodes: vi.fn(),
mockTranslateWalkedElement: vi.fn(),
mockTranslateTextForPageTitle: vi.fn(),
mockValidateTranslationConfigAndToast: vi.fn(),
mockSendMessage: vi.fn(),
}))
vi.mock("@/utils/config/languages", () => ({
getDetectedCodeFromStorage: mockGetDetectedCodeFromStorage,
}))
vi.mock("@/utils/config/storage", () => ({
getLocalConfig: mockGetLocalConfig,
}))
vi.mock("@/utils/crypto-polyfill", () => ({
getRandomUUID: () => "walk-id",
}))
vi.mock("@/utils/host/dom/filter", () => ({
hasNoWalkAncestor: mockHasNoWalkAncestor,
isDontWalkIntoAndDontTranslateAsChildElement: mockIsDontWalkIntoAndDontTranslateAsChildElement,
isDontWalkIntoButTranslateAsChildElement: mockIsDontWalkIntoButTranslateAsChildElement,
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
}))
vi.mock("@/utils/host/dom/find", () => ({
deepQueryTopLevelSelector: mockDeepQueryTopLevelSelector,
}))
vi.mock("@/utils/host/dom/traversal", () => ({
walkAndLabelElement: mockWalkAndLabelElement,
}))
vi.mock("@/utils/host/translate/node-manipulation", () => ({
removeAllTranslatedWrapperNodes: mockRemoveAllTranslatedWrapperNodes,
translateWalkedElement: mockTranslateWalkedElement,
}))
vi.mock("@/utils/host/translate/translate-text", () => ({
validateTranslationConfigAndToast: mockValidateTranslationConfigAndToast,
}))
vi.mock("@/utils/host/translate/translate-variants", () => ({
translateTextForPageTitle: mockTranslateTextForPageTitle,
}))
vi.mock("@/utils/host/translate/webpage-context", () => ({
getOrCreateWebPageContext: mockGetOrCreateWebPageContext,
}))
vi.mock("@/utils/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/utils/message", () => ({
sendMessage: mockSendMessage,
}))
const intersectionObservers: MockIntersectionObserver[] = []
class MockIntersectionObserver {
observe = vi.fn((target: Element) => {
this.targets.add(target)
})
unobserve = vi.fn((target: Element) => {
this.targets.delete(target)
})
disconnect = vi.fn(() => {
this.targets.clear()
})
private readonly targets = new Set<Element>()
constructor(
private readonly callback: IntersectionObserverCallback,
_options?: IntersectionObserverInit,
) {
intersectionObservers.push(this)
}
async triggerIntersect(target: Element): Promise<void> {
await this.callback([{
isIntersecting: true,
target,
} as IntersectionObserverEntry], this as unknown as IntersectionObserver)
}
}
async function flushDomUpdates(): Promise<void> {
await Promise.resolve()
await new Promise(resolve => setTimeout(resolve, 0))
await Promise.resolve()
}
function deepQueryTopLevelSelectorImpl(
root: Document | ShadowRoot | HTMLElement,
selectorFn: (element: HTMLElement) => boolean,
): HTMLElement[] {
if (root instanceof Document) {
return root.body ? deepQueryTopLevelSelectorImpl(root.body, selectorFn) : []
}
if (root instanceof HTMLElement && selectorFn(root)) {
return [root]
}
const result: HTMLElement[] = []
if (root instanceof HTMLElement && root.shadowRoot) {
result.push(...deepQueryTopLevelSelectorImpl(root.shadowRoot, selectorFn))
}
for (const child of root.children) {
if (child instanceof HTMLElement) {
result.push(...deepQueryTopLevelSelectorImpl(child, selectorFn))
}
}
return result
}
function isBlockedForTraversal(element: HTMLElement): boolean {
return Boolean(element.hidden)
|| element.getAttribute("aria-hidden") === "true"
|| element.classList.contains("closed")
}
function walkAndLabelVisibleParagraphs(element: HTMLElement, walkId: string) {
if (isBlockedForTraversal(element)) {
return {
forceBlock: false,
isInlineNode: false,
}
}
element.setAttribute("data-read-frog-walked", walkId)
for (const child of element.children) {
if (child instanceof HTMLElement) {
walkAndLabelVisibleParagraphs(child, walkId)
}
}
if (element.tagName === "P" && element.textContent?.trim()) {
element.setAttribute("data-read-frog-paragraph", "")
}
return {
forceBlock: false,
isInlineNode: false,
}
}
describe("pageTranslationManager mutation re-walk", () => {
beforeEach(() => {
vi.clearAllMocks()
intersectionObservers.length = 0
document.head.innerHTML = ""
document.body.innerHTML = ""
document.title = ""
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver)
mockGetDetectedCodeFromStorage.mockResolvedValue("eng")
mockGetLocalConfig.mockResolvedValue(DEFAULT_CONFIG)
mockGetOrCreateWebPageContext.mockResolvedValue({
url: window.location.href,
webTitle: "",
webContent: "",
})
mockHasNoWalkAncestor.mockReturnValue(false)
mockIsDontWalkIntoButTranslateAsChildElement.mockReturnValue(false)
mockIsDontWalkIntoAndDontTranslateAsChildElement.mockImplementation((element: HTMLElement) => isBlockedForTraversal(element))
mockDeepQueryTopLevelSelector.mockImplementation(deepQueryTopLevelSelectorImpl)
mockWalkAndLabelElement.mockImplementation((element: HTMLElement, walkId: string) => walkAndLabelVisibleParagraphs(element, walkId))
mockTranslateTextForPageTitle.mockResolvedValue("")
mockValidateTranslationConfigAndToast.mockReturnValue(true)
mockSendMessage.mockResolvedValue(undefined)
})
it("observes and translates hidden accordion content after it becomes visible", async () => {
document.body.innerHTML = `
<section id="accordion" hidden>
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.removeAttribute("hidden")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
it("observes and translates aria-hidden accordion content after it becomes visible", async () => {
document.body.innerHTML = `
<section id="accordion" aria-hidden="true">
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.setAttribute("aria-hidden", "false")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
it("keeps style/class based re-walk behavior for existing hidden panels", async () => {
document.body.innerHTML = `
<section id="accordion" class="closed">
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.classList.remove("closed")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
})

View file

@ -8,7 +8,7 @@ const {
mockDeepQueryTopLevelSelector,
mockGetDetectedCodeFromStorage,
mockGetLocalConfig,
mockGetOrCreateWebPageContext,
mockGetOrFetchArticleData,
mockRemoveAllTranslatedWrapperNodes,
mockSendMessage,
mockTranslateTextForPageTitle,
@ -23,7 +23,7 @@ const {
mockRemoveAllTranslatedWrapperNodes: vi.fn(),
mockTranslateWalkedElement: vi.fn(),
mockTranslateTextForPageTitle: vi.fn(),
mockGetOrCreateWebPageContext: vi.fn(),
mockGetOrFetchArticleData: vi.fn(),
mockValidateTranslationConfigAndToast: vi.fn(),
mockSendMessage: vi.fn(),
}))
@ -38,7 +38,6 @@ vi.mock("@/utils/config/storage", () => ({
vi.mock("@/utils/host/dom/filter", () => ({
hasNoWalkAncestor: vi.fn().mockReturnValue(false),
isDontWalkIntoAndDontTranslateAsChildElement: vi.fn().mockReturnValue(false),
isDontWalkIntoButTranslateAsChildElement: vi.fn().mockReturnValue(false),
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
}))
@ -60,8 +59,8 @@ vi.mock("@/utils/host/translate/translate-variants", () => ({
translateTextForPageTitle: mockTranslateTextForPageTitle,
}))
vi.mock("@/utils/host/translate/webpage-context", () => ({
getOrCreateWebPageContext: mockGetOrCreateWebPageContext,
vi.mock("@/utils/host/translate/article-context", () => ({
getOrFetchArticleData: mockGetOrFetchArticleData,
}))
vi.mock("@/utils/host/translate/translate-text", () => ({
@ -116,47 +115,11 @@ describe("pageTranslationManager title handling", () => {
mockGetDetectedCodeFromStorage.mockResolvedValue("eng")
mockGetLocalConfig.mockResolvedValue(DEFAULT_CONFIG)
mockDeepQueryTopLevelSelector.mockReturnValue([])
mockGetOrCreateWebPageContext.mockResolvedValue({
url: window.location.href,
webTitle: "Original Title",
webContent: "Article body",
})
mockGetOrFetchArticleData.mockResolvedValue({ title: "Original Title" })
mockValidateTranslationConfigAndToast.mockReturnValue(true)
mockSendMessage.mockResolvedValue(undefined)
})
it("does not prime webpage context on start for non-llm translation", async () => {
mockTranslateTextForPageTitle.mockResolvedValue("Translated Title")
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
expect(mockGetOrCreateWebPageContext).not.toHaveBeenCalled()
manager.stop()
})
it("primes webpage context on start for AI-aware llm translation", async () => {
mockGetLocalConfig.mockResolvedValue({
...DEFAULT_CONFIG,
translate: {
...DEFAULT_CONFIG.translate,
providerId: "openai-default",
enableAIContentAware: true,
},
})
mockTranslateTextForPageTitle.mockResolvedValue("Translated Title")
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
expect(mockGetOrCreateWebPageContext).toHaveBeenCalledTimes(1)
manager.stop()
})
it("translates the tab title on start and restores the latest source title on stop", async () => {
mockTranslateTextForPageTitle
.mockResolvedValueOnce("Translated Title")

View file

@ -1,20 +1,17 @@
import type { FeatureUsageContext } from "@/types/analytics"
import type { Config } from "@/types/config/config"
import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics"
import { isLLMProviderConfig } from "@/types/config/provider"
import { createFeatureUsageContext, trackFeatureUsed } from "@/utils/analytics"
import { getDetectedCodeFromStorage } from "@/utils/config/languages"
import { getLocalConfig } from "@/utils/config/storage"
import { CONTENT_WRAPPER_CLASS } from "@/utils/constants/dom-labels"
import { resolveProviderConfig } from "@/utils/constants/feature-providers"
import { getRandomUUID } from "@/utils/crypto-polyfill"
import { hasNoWalkAncestor, isDontWalkIntoAndDontTranslateAsChildElement, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { hasNoWalkAncestor, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { deepQueryTopLevelSelector } from "@/utils/host/dom/find"
import { walkAndLabelElement } from "@/utils/host/dom/traversal"
import { getOrFetchArticleData } from "@/utils/host/translate/article-context"
import { removeAllTranslatedWrapperNodes, translateWalkedElement } from "@/utils/host/translate/node-manipulation"
import { validateTranslationConfigAndToast } from "@/utils/host/translate/translate-text"
import { translateTextForPageTitle } from "@/utils/host/translate/translate-variants"
import { getOrCreateWebPageContext } from "@/utils/host/translate/webpage-context"
import { logger } from "@/utils/logger"
import { sendMessage } from "@/utils/message"
@ -60,7 +57,7 @@ export class PageTranslationManager implements IPageTranslationManager {
private mutationObservers: MutationObserver[] = []
private walkId: string | null = null
private intersectionOptions: IntersectionObserverInit
private walkBlockedElementsCache = new WeakSet<HTMLElement>()
private dontWalkIntoElementsCache = new WeakSet<HTMLElement>()
private titleObserver: MutationObserver | null = null
private lastSourceTitle: string | null = null
private lastAppliedTranslatedTitle: string | null = null
@ -120,16 +117,12 @@ export class PageTranslationManager implements IPageTranslationManager {
}
try {
const providerConfig = resolveProviderConfig(config, "translate")
await sendMessage("setAndNotifyPageTranslationStateChangedByManager", {
enabled: true,
})
this.isPageTranslating = true
await this.primeDocumentTitleContext(
config.translate.enableAIContentAware && isLLMProviderConfig(providerConfig),
)
await this.primeDocumentTitleContext(config.translate.enableAIContentAware)
this.startDocumentTitleTracking()
// Listen to existing elements when they enter the viewpoint
@ -154,8 +147,8 @@ export class PageTranslationManager implements IPageTranslationManager {
}, this.intersectionOptions)
// Initialize walkability state for existing elements
this.addWalkBlockedElements(document.body, config)
await this.observerTopLevelParagraphs(document.body, config)
this.addDontWalkIntoElements(document.body)
await this.observerTopLevelParagraphs(document.body)
// Start observing mutations from document.body and all shadow roots
this.observeMutations(document.body)
@ -190,7 +183,7 @@ export class PageTranslationManager implements IPageTranslationManager {
this.isPageTranslating = false
this.walkId = null
this.walkBlockedElementsCache = new WeakSet()
this.dontWalkIntoElementsCache = new WeakSet()
this.stopDocumentTitleTracking()
if (this.intersectionObserver) {
@ -268,16 +261,16 @@ export class PageTranslationManager implements IPageTranslationManager {
return window === window.top
}
private async primeDocumentTitleContext(shouldPrimeWebPageContext: boolean): Promise<void> {
if (!this.shouldManageDocumentTitle() || !shouldPrimeWebPageContext) {
private async primeDocumentTitleContext(enableAIContentAware: boolean): Promise<void> {
if (!this.shouldManageDocumentTitle()) {
return
}
try {
await getOrCreateWebPageContext()
await getOrFetchArticleData(enableAIContentAware)
}
catch (error) {
logger.warn("Failed to prime webpage context before translating document title:", error)
logger.warn("Failed to prime article context before translating document title:", error)
}
}
@ -387,12 +380,12 @@ export class PageTranslationManager implements IPageTranslationManager {
}
}
private async observerTopLevelParagraphs(container: HTMLElement, existingConfig?: Config): Promise<void> {
private async observerTopLevelParagraphs(container: HTMLElement): Promise<void> {
const observer = this.intersectionObserver
if (!this.walkId || !observer)
return
const config = existingConfig ?? await getLocalConfig()
const config = await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
@ -455,39 +448,33 @@ export class PageTranslationManager implements IPageTranslationManager {
}
/**
* Track the same blocked states that the traversal skips, so hidden accordion
* panels can be re-walked when the site reveals an existing subtree.
* Handle style/class attribute changes and only trigger observation
* when element transitions from "don't walk into" to "walkable"
*/
private isWalkBlockedElement(element: HTMLElement, config: Config): boolean {
return isDontWalkIntoButTranslateAsChildElement(element)
|| isDontWalkIntoAndDontTranslateAsChildElement(element, config)
}
/**
* Handle attribute changes and only trigger observation
* when element transitions from blocked to walkable.
*/
private didChangeToWalkable(element: HTMLElement, config: Config): boolean {
const wasWalkBlocked = this.walkBlockedElementsCache.has(element)
const isWalkBlockedNow = this.isWalkBlockedElement(element, config)
private didChangeToWalkable(element: HTMLElement): boolean {
const wasDontWalkInto = this.dontWalkIntoElementsCache.has(element)
const isDontWalkIntoNow = isDontWalkIntoButTranslateAsChildElement(element)
// Update cache with current state
if (isWalkBlockedNow) {
this.walkBlockedElementsCache.add(element)
if (isDontWalkIntoNow) {
this.dontWalkIntoElementsCache.add(element)
}
else {
this.walkBlockedElementsCache.delete(element)
this.dontWalkIntoElementsCache.delete(element)
}
return wasWalkBlocked === true && isWalkBlockedNow === false
// Only trigger observation if element transitioned from "don't walk into" to "walkable"
// wasDontWalkInto === true means it was previously not walkable
// isDontWalkIntoNow === false means it's now walkable
return wasDontWalkInto === true && isDontWalkIntoNow === false
}
/**
* Initialize walkability state for an element and its descendants
*/
private addWalkBlockedElements(element: HTMLElement, config: Config): void {
const walkBlockedElements = deepQueryTopLevelSelector(element, el => this.isWalkBlockedElement(el, config))
walkBlockedElements.forEach(el => this.walkBlockedElementsCache.add(el))
private addDontWalkIntoElements(element: HTMLElement): void {
const dontWalkIntoElements = deepQueryTopLevelSelector(element, isDontWalkIntoButTranslateAsChildElement)
dontWalkIntoElements.forEach(el => this.dontWalkIntoElementsCache.add(el))
}
/**
@ -495,54 +482,39 @@ export class PageTranslationManager implements IPageTranslationManager {
*/
private observeMutations(container: HTMLElement): void {
const mutationObserver = new MutationObserver((records) => {
void this.handleMutationRecords(records)
for (const rec of records) {
if (rec.type === "childList") {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addDontWalkIntoElements(node)
void this.observerTopLevelParagraphs(node)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (
rec.type === "attributes"
&& (rec.attributeName === "style" || rec.attributeName === "class")
) {
const el = rec.target
if (isHTMLElement(el) && this.didChangeToWalkable(el)) {
void this.observerTopLevelParagraphs(el)
}
}
}
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class", "hidden", "aria-hidden"],
attributeFilter: ["style", "class"],
})
this.mutationObservers.push(mutationObserver)
this.observeIsolatedDescendantsMutations(container)
}
private async handleMutationRecords(records: MutationRecord[]): Promise<void> {
const config = await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
}
for (const rec of records) {
if (rec.type === "childList") {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addWalkBlockedElements(node, config)
void this.observerTopLevelParagraphs(node, config)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (this.isWalkabilityAttributeMutation(rec)) {
const el = rec.target
if (isHTMLElement(el) && this.didChangeToWalkable(el, config)) {
void this.observerTopLevelParagraphs(el, config)
}
}
}
}
private isWalkabilityAttributeMutation(record: MutationRecord): boolean {
return record.type === "attributes"
&& (record.attributeName === "style"
|| record.attributeName === "class"
|| record.attributeName === "hidden"
|| record.attributeName === "aria-hidden")
}
/**
* Recursively find and observe shadow roots and iframes in an element and its descendants
* These can't be find as top level paragraph elements because isolated shadow roots and iframes are not

View file

@ -2,8 +2,7 @@ import { defineContentScript } from "#imports"
import { injectPlayerApi } from "./inject-player-api"
export default defineContentScript({
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*"],
allFrames: true,
matches: ["*://*.youtube.com/*"],
world: "MAIN",
runAt: "document_start",
main() {

View file

@ -37,15 +37,10 @@ declare global {
function findYoutubePlayer(): YouTubePlayer | null {
return document.querySelector(
".html5-video-player.playing-mode, .html5-video-player.paused-mode",
) ?? document.querySelector(".html5-video-player")
)
}
export function injectPlayerApi(): void {
if ((window as any).__READ_FROG_INTERCEPTOR_INJECTED__) {
return
}
;(window as any).__READ_FROG_INTERCEPTOR_INJECTED__ = true
setupTimedtextObserver()
window.addEventListener("message", handleMessage)
}

View file

@ -16,17 +16,6 @@ vi.mock("#imports", () => ({
},
}))
vi.mock("@iconify/react", () => ({
Icon: ({ className, icon }: { className?: string, icon: string }) => (
<span
aria-hidden="true"
className={className}
data-icon={icon}
data-testid="whats-new-footer-icon"
/>
),
}))
vi.mock("@/components/ui/base-ui/sidebar", () => ({
SidebarMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SidebarMenuButton: ({ children, ...props }: React.ComponentProps<"button">) => (

View file

@ -15,7 +15,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/base-ui/sidebar"
import { env } from "@/env"
import {
buildBilibiliEmbedUrl,
getBlogLocaleFromUILanguage,
@ -24,6 +23,7 @@ import {
hasNewBlogPost,
saveLastViewedBlogDate,
} from "@/utils/blog"
import { WEBSITE_URL } from "@/utils/constants/url"
import { version } from "../../../../package.json"
export function WhatsNewFooter() {
@ -38,7 +38,7 @@ export function WhatsNewFooter() {
const { data: latestBlogPost, isFetched: isLatestBlogPostFetched } = useQuery({
queryKey: ["latest-blog-post", blogLocale],
queryFn: () => getLatestBlogDate(`${env.WXT_WEBSITE_URL}/api/blog/latest`, blogLocale, version),
queryFn: () => getLatestBlogDate(`${WEBSITE_URL}/api/blog/latest`, blogLocale, version),
})
const markLatestBlogPostViewed = useEffectEvent(async () => {
@ -63,7 +63,7 @@ export function WhatsNewFooter() {
}, [])
const openPopover = useEffectEvent(() => {
// eslint-disable-next-line react/set-state-in-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setOpen(true)
})
@ -97,7 +97,7 @@ export function WhatsNewFooter() {
return null
}
const blogUrl = new URL(latestBlogPost.url, env.WXT_WEBSITE_URL).toString()
const blogUrl = new URL(latestBlogPost.url, WEBSITE_URL).toString()
const embedUrl = latestBlogPost.videoUrl ? buildBilibiliEmbedUrl(latestBlogPost.videoUrl) : null
return (

View file

@ -22,23 +22,17 @@ vi.mock("@/components/ui/json-code-editor", () => ({
JSONCodeEditor: ({
value,
onChange,
onBlur,
onFocus,
placeholder,
}: {
value?: string
onChange?: (value: string) => void
onBlur?: () => void
onFocus?: () => void
placeholder?: string
}) => (
<textarea
aria-label="provider-options-editor"
value={value}
placeholder={placeholder}
onBlur={onBlur}
onChange={event => onChange?.(event.target.value)}
onFocus={onFocus}
/>
),
}))
@ -59,22 +53,16 @@ const baseProviderConfig: APIProviderConfig = {
function ProviderOptionsFieldHarness({
initialConfig,
externalProviderOptions,
submitDelayMs = 0,
}: {
initialConfig: APIProviderConfig
externalProviderOptions?: Record<string, unknown>
submitDelayMs?: number
}) {
const [providerConfig, setProviderConfig] = useState(initialConfig)
const form = useAppForm({
...formOpts,
defaultValues: providerConfig,
onSubmit: async ({ value }) => {
if (submitDelayMs > 0) {
await new Promise(resolve => window.setTimeout(resolve, submitDelayMs))
}
setProviderConfig(submitDelayMs > 0 ? structuredClone(value) : value)
setProviderConfig(value)
},
})
@ -117,7 +105,6 @@ describe("providerOptionsField", () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
await act(async () => {
@ -128,29 +115,6 @@ describe("providerOptionsField", () => {
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"minimal\"}")
})
it("keeps focused draft edits when a delayed autosave echo arrives", async () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} submitDelayMs={100} />)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
await act(async () => {
vi.advanceTimersByTime(500)
await Promise.resolve()
})
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"low\"}" } })
await act(async () => {
vi.advanceTimersByTime(100)
await Promise.resolve()
await Promise.resolve()
})
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"low\"}")
})
it("shows the matched recommended provider options as the placeholder when the value is empty", () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
@ -181,27 +145,6 @@ describe("providerOptionsField", () => {
)
})
it("matches recommendations by model name even when the provider differs", () => {
render(
<ProviderOptionsFieldHarness
initialConfig={{
...baseProviderConfig,
provider: "groq",
model: {
model: "qwen/qwen3-32b",
isCustomModel: false,
customModel: null,
},
}}
/>,
)
expect(screen.getByLabelText("provider-options-editor")).toHaveAttribute(
"placeholder",
JSON.stringify({ enableThinking: false }, null, 2),
)
})
it("syncs the editor when an external update arrives, even if the saved value is unchanged", async () => {
const externalProviderOptions = { enableThinking: false }
@ -216,9 +159,7 @@ describe("providerOptionsField", () => {
)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{" } })
fireEvent.blur(editor)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
await Promise.resolve()

View file

@ -116,18 +116,4 @@ describe("providerOptionsRecommendationTrigger", () => {
expect(onApply).toHaveBeenCalledWith({ reasoningEffort: "none" })
})
it("does not render Kimi instruct recommendations based on model name alone", () => {
render(
<ProviderOptionsRecommendationTrigger
providerId="provider-1"
modelId="moonshotai/Kimi-K2-Instruct"
onApply={vi.fn()}
/>,
)
expect(screen.queryByRole("button", {
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
})).not.toBeInTheDocument()
})
})

View file

@ -55,7 +55,7 @@ export function ConnectionTestButton({ providerConfig }: { providerConfig: APIPr
useEffect(() => {
mutation.reset()
// eslint-disable-next-line react/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, apiKey, baseURL, connectionOptions])
const testResult = mutation.isSuccess ? "success" : mutation.isError ? "error" : null

View file

@ -38,17 +38,17 @@ export function ProviderOptionsRecommendationTrigger({
const previousMatchIndexRef = useRef<number | undefined>(undefined)
const closePopover = useEffectEvent(() => {
// eslint-disable-next-line react/set-state-in-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setOpen(false)
})
const startFlashing = useEffectEvent(() => {
// eslint-disable-next-line react/set-state-in-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setIsFlashing(true)
})
const stopFlashing = useEffectEvent(() => {
// eslint-disable-next-line react/set-state-in-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setIsFlashing(false)
})

View file

@ -2,8 +2,8 @@ import type { APIProviderTypes } from "@/types/config/provider"
import { i18n } from "#imports"
import ProviderIcon from "@/components/provider-icon"
import { useTheme } from "@/components/providers/theme-provider"
import { env } from "@/env"
import { PROVIDER_GROUPS, PROVIDER_ITEMS, SPECIFIC_TUTORIAL_PROVIDER_TYPES } from "@/utils/constants/providers"
import { WEBSITE_URL } from "@/utils/constants/url"
export function ConfigHeader({ providerType }: { providerType: APIProviderTypes }) {
const tutorialUrl = getHowToConfigureURL(providerType)
@ -31,13 +31,13 @@ export function ConfigHeader({ providerType }: { providerType: APIProviderTypes
function getHowToConfigureURL(providerType: APIProviderTypes): string | undefined {
if (SPECIFIC_TUTORIAL_PROVIDER_TYPES.includes(providerType as any)) {
return `${env.WXT_WEBSITE_URL}/docs/providers/${providerType}`
return `${WEBSITE_URL}/tutorial/providers/${providerType}`
}
const groupSlug = getProviderGroupSlug(providerType)
if (!groupSlug)
return undefined
return `${env.WXT_WEBSITE_URL}/docs/providers/${groupSlug}`
return `${WEBSITE_URL}/tutorial/providers/${groupSlug}`
}
function getProviderGroupSlug(providerType: APIProviderTypes): string | undefined {

View file

@ -22,7 +22,7 @@ export const ConnectionOptionsField = withForm({
// Sync local state when switching provider
const syncLocalOptions = useEffectEvent(() => {
// eslint-disable-next-line react/set-state-in-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setLocalOptions(providerConfig.connectionOptions ?? {})
})

View file

@ -39,10 +39,9 @@ export const ProviderOptionsField = withForm({
const [jsonInput, setJsonInput] = useState(() => externalJson)
const lastCommittedJsonRef = useRef(externalJson)
const pendingEditorCommitRef = useRef(false)
const editorFocusedRef = useRef(false)
const syncJsonInput = useEffectEvent((nextJson: string) => {
// eslint-disable-next-line react/set-state-in-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setJsonInput(nextJson)
})
@ -56,18 +55,6 @@ export const ProviderOptionsField = withForm({
return jsonInput
})
const handleJsonInputChange = useCallback((nextJson: string) => {
setJsonInput(nextJson)
}, [])
const handleEditorFocus = useCallback(() => {
editorFocusedRef.current = true
}, [])
const handleEditorBlur = useCallback(() => {
editorFocusedRef.current = false
}, [])
useEffect(() => {
resetSyncStateForProvider()
}, [providerConfig.id])
@ -79,15 +66,9 @@ export const ProviderOptionsField = withForm({
}
pendingEditorCommitRef.current = false
const currentJsonInput = readJsonInput()
if (editorFocusedRef.current && currentJsonInput !== lastCommittedJsonRef.current) {
return
}
lastCommittedJsonRef.current = externalJson
if (currentJsonInput !== externalJson) {
if (readJsonInput() !== externalJson) {
syncJsonInput(externalJson)
}
}, [providerConfig.providerOptions, externalJson])
@ -146,9 +127,7 @@ export const ProviderOptionsField = withForm({
</FieldLabel>
<JSONCodeEditor
value={jsonInput}
onChange={handleJsonInputChange}
onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
onChange={setJsonInput}
placeholder={placeholderText}
hasError={!!jsonError}
height="150px"

View file

@ -140,11 +140,11 @@ function MoreOptions({ backupId, backup }: { backupId: string, backup: ConfigBac
<Icon icon="tabler:dots" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="end">
<DropdownMenuItem onClick={() => setShowExportDialog(true)} disabled={isExporting}>
<DropdownMenuItem onSelect={() => setShowExportDialog(true)} disabled={isExporting}>
<Icon icon="tabler:file-export" />
{i18n.t("options.config.backup.item.export")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowDeleteDialog(true)}>
<DropdownMenuItem onSelect={() => setShowDeleteDialog(true)}>
<Icon icon="tabler:trash" />
{i18n.t("options.config.backup.item.delete")}
</DropdownMenuItem>

View file

@ -94,7 +94,7 @@ function DialogContent({ onResolved, onCancelled }: DialogContentProps) {
const canConfirm = status.isValid && !isConfirming
return (
<AlertDialogContent className="data-[size=default]:max-w-[calc(100vw-2rem)] data-[size=default]:md:max-w-2xl data-[size=default]:lg:max-w-4xl data-[size=default]:xl:max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<AlertDialogContent className="w-[min(80rem,calc(100vw-2rem))] max-w-none max-h-[90vh] flex flex-col overflow-hidden">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Icon icon="mdi:alert" className="size-5 text-yellow-500" />

Some files were not shown because too many files have changed in this diff Show more