This commit is contained in:
Tommy D. Rossi
2026-02-22 15:21:38 +01:00
parent e475c2f582
commit f87b0243cd
101 changed files with 14997 additions and 11339 deletions
+7 -5
View File
@@ -15,7 +15,9 @@ Dark mode uses `prefers-color-scheme` media query, configured in `globals.css`:
```css
/* WRONG - silently produces no output */
@variant dark {
.my-class { color: white; }
.my-class {
color: white;
}
}
/* CORRECT - nest inside the selector */
@@ -37,9 +39,9 @@ Dark mode uses `prefers-color-scheme` media query, configured in `globals.css`:
```css
/* globals.css — this is the Tailwind entry point */
@import "tailwindcss";
@import "./editorial.css"; /* editorial page styles (class names, layout) */
@import "./editorial-prism.css"; /* prism syntax highlighting */
@import 'tailwindcss';
@import './editorial.css'; /* editorial page styles (class names, layout) */
@import './editorial-prism.css'; /* prism syntax highlighting */
@custom-variant dark (@media (prefers-color-scheme: dark));
```
@@ -54,7 +56,7 @@ For files with many dark mode selectors (like prism syntax colors), define CSS v
Every `<img>` must have explicit `width` and `height` attributes matching the intrinsic pixel dimensions of the source file. Add `style={{ height: "auto" }}` to keep it responsive. This lets the browser reserve the correct aspect ratio space before the image loads, preventing layout shift.
```tsx
<img src="/photo.png" width={1280} height={800} style={{ maxWidth: "100%", height: "auto" }} />
<img src='/photo.png' width={1280} height={800} style={{ maxWidth: '100%', height: 'auto' }} />
```
Use `sips -g pixelWidth -g pixelHeight <file>` to get dimensions on macOS.
+1 -3
View File
@@ -3,9 +3,7 @@
{
"name": "playwriter",
"description": "Control the user own Chrome browser via Playwriter extension with Playwright code snippets in a stateful local js sandbox via playwriter cli. Use this over other Playwright MCPs to automate the browser — it connects to the user's existing Chrome instead of launching a new one. Use this for JS-heavy websites (Instagram, Twitter, cookie/login walls, lazy-loaded UIs) instead of webfetch/curl. Run `playwriter skill` command to read the complete up to date skill",
"files": [
"SKILL.md"
]
"files": ["SKILL.md"]
}
]
}
@@ -12,6 +12,7 @@ playwriter skill
```
This outputs the complete documentation including:
- Session management and timeout configuration
- Selector strategies (and which ones to AVOID)
- Rules to prevent timeouts and failures
+225 -166
View File
@@ -14,6 +14,7 @@ If using npx or bunx always use @latest for the first session command. so we are
### Session management
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
- Keep state separate between different tasks or agents
- Persist data (pages, variables) across multiple execute calls
- Avoid interference when multiple agents use playwriter simultaneously
@@ -213,30 +214,33 @@ Each step is a separate execute call. Notice how every action is followed by a s
```js
// 1. Open page and observe — always print URL first
state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage }).then(console.log)
state.myPage = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' })
console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage }).then(console.log)
```
```js
// 2. Act: open command palette → observe result
await state.myPage.keyboard.press('Meta+k');
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log)
await state.myPage.keyboard.press('Meta+k')
console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log)
// If dialog didn't appear, observe again before retrying
```
```js
// 3. Act: type search query → observe result
await state.myPage.keyboard.type('MCP');
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
await state.myPage.keyboard.type('MCP')
console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
```
```js
// 4. Act: press Enter → observe plugin loaded
await state.myPage.keyboard.press('Enter');
await state.myPage.waitForTimeout(1000);
console.log('URL:', state.myPage.url());
const frame = state.myPage.frames().find(f => f.url().includes('plugins.framercdn.com'));
await state.myPage.keyboard.press('Enter')
await state.myPage.waitForTimeout(1000)
console.log('URL:', state.myPage.url())
const frame = state.myPage.frames().find((f) => f.url().includes('plugins.framercdn.com'))
await snapshot({ page: state.myPage, frame: frame || undefined }).then(console.log)
// If frame not found, wait and observe again — plugin may still be loading
```
@@ -251,7 +255,11 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
```
- **Network requests** — verify API calls were made after a form submit or button click:
```js
page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
page.on('response', async (res) => {
if (res.url().includes('/api/')) {
console.log(res.status(), res.url())
}
})
```
- **URL changes** — confirm navigation happened:
```js
@@ -263,28 +271,31 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
**1. Not verifying actions succeeded**
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
```js
await page.keyboard.type('my text');
await page.keyboard.type('my text')
await snapshot({ page, search: /my text/ })
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
```
**2. Assuming paste/upload worked**
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
```js
// Reliable: use file input
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles('/path/to/image.png');
const fileInput = page.locator('input[type="file"]').first()
await fileInput.setInputFiles('/path/to/image.png')
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
await page.keyboard.press('Meta+v'); // always verify with screenshot!
await page.keyboard.press('Meta+v') // always verify with screenshot!
```
**3. Using stale locators from old snapshots**
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
```js
// BAD: using ref from minutes ago
await page.locator('[id="old-id"]').click(); // element may have changed
await page.locator('[id="old-id"]').click() // element may have changed
// GOOD: get fresh snapshot, then immediately use locators from it
await snapshot({ page, showDiffSinceLastCall: true })
@@ -293,28 +304,31 @@ await snapshot({ page, showDiffSinceLastCall: true })
**4. Wrong assumptions about current page/element**
Before destructive actions (delete, submit), verify you're targeting the right thing:
```js
// Before deleting, verify it's the right item
await screenshotWithAccessibilityLabels({ page });
await screenshotWithAccessibilityLabels({ page })
// READ the screenshot to confirm, THEN proceed with delete
```
**5. Text concatenation without line breaks**
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
```js
// BAD: newlines in string don't create line breaks
await page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
await page.keyboard.type('Line 1\nLine 2') // becomes "Line 1Line 2"
// GOOD: use Enter key for line breaks
await page.keyboard.type('Line 1');
await page.keyboard.press('Enter');
await page.keyboard.type('Line 2');
await page.keyboard.type('Line 1')
await page.keyboard.press('Enter')
await page.keyboard.type('Line 2')
```
**6. Quote escaping in $'...' syntax**
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
```bash
# BAD: nested double quotes break $'...'
# BAD: nested double quotes break $'...'
playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()'
# GOOD: use single quotes inside, or template strings
@@ -329,47 +343,50 @@ EOF
**7. Using screenshots when snapshots suffice**
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
```js
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
await page.screenshot({ path: 'check.png', scale: 'css' });
await page.screenshot({ path: 'check.png', scale: 'css' })
// GOOD: snapshot is text — fast, cheap, searchable
await snapshot({ page, search: /expected text/i })
// GOOD: evaluate DOM directly for content checks
const text = await page.evaluate(() => document.querySelector('.message')?.textContent);
const text = await page.evaluate(() => document.querySelector('.message')?.textContent)
```
**8. Assuming page content loaded**
Even after `goto()`, dynamic content may not be ready:
```js
await page.goto('https://example.com');
await page.goto('https://example.com')
// Content may still be loading via JavaScript!
await page.waitForSelector('article', { timeout: 10000 });
await page.waitForSelector('article', { timeout: 10000 })
// Or use waitForPageLoad utility
await waitForPageLoad({ page, timeout: 5000 });
await waitForPageLoad({ page, timeout: 5000 })
```
**9. Login buttons that open popups**
Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead:
```js
// BAD: popup window is not controllable by playwriter
await page.click('button:has-text("Login with Google")');
await page.click('button:has-text("Login with Google")')
// GOOD: cmd+click opens in new tab that playwriter can control
await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
await page.waitForTimeout(2000);
await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
await page.waitForTimeout(2000)
// Verify new tab opened - last page should be the login page
const pages = context.pages();
const loginPage = pages[pages.length - 1];
const pages = context.pages()
const loginPage = pages[pages.length - 1]
if (loginPage.url() === page.url()) {
throw new Error('Cmd+click did not open new tab - login may have opened as popup');
throw new Error('Cmd+click did not open new tab - login may have opened as popup')
}
// Complete login flow in loginPage, cookies are shared with original page
await loginPage.locator('[data-email]').first().click();
await loginPage.waitForURL('**/callback**');
await loginPage.locator('[data-email]').first().click()
await loginPage.waitForURL('**/callback**')
// Original page should now be authenticated
```
@@ -379,10 +396,12 @@ After any action (click, submit, navigate), verify what happened. Always print U
```js
// Always print URL first, then snapshot
console.log('URL:', page.url()); await snapshot({ page }).then(console.log)
console.log('URL:', page.url())
await snapshot({ page }).then(console.log)
// Filter for specific content when snapshot is large
console.log('URL:', page.url()); await snapshot({ page, search: /dialog|button|error/i }).then(console.log)
console.log('URL:', page.url())
await snapshot({ page, search: /dialog|button|error/i }).then(console.log)
```
If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element.
@@ -404,10 +423,10 @@ Example output:
```md
- banner:
- link "Home" [id="nav-home"]
- navigation:
- link "Docs" [data-testid="docs-link"]
- link "Blog" role=link[name="Blog"]
- link "Home" [id="nav-home"]
- navigation:
- link "Docs" [data-testid="docs-link"]
- link "Blog" role=link[name="Blog"]
```
Each interactive line ends with a Playwright locator you can pass to `page.locator()`.
@@ -437,11 +456,12 @@ const snap = await snapshot({ page, search: /button|submit/i })
**Filtering large snapshots in JS** — when the built-in `search` isn't enough (e.g., you need multiple patterns or custom logic), filter the snapshot string directly:
```js
const snap = await snapshot({ page, showDiffSinceLastCall: false });
const relevant = snap.split('\n').filter(l =>
l.includes('dialog') || l.includes('error') || l.includes('button')
).join('\n');
console.log(relevant);
const snap = await snapshot({ page, showDiffSinceLastCall: false })
const relevant = snap
.split('\n')
.filter((l) => l.includes('dialog') || l.includes('error') || l.includes('button'))
.join('\n')
console.log(relevant)
```
This is much cheaper than taking a screenshot — use it as your primary debugging tool for verifying text content, checking if elements exist, or confirming state changes.
@@ -451,12 +471,14 @@ This is much cheaper than taking a screenshot — use it as your primary debuggi
Both `snapshot` and `screenshotWithAccessibilityLabels` use the same ref system, so you can combine them effectively.
**Use `snapshot` when:**
- Page has simple, semantic structure (articles, forms, lists)
- You need to search for specific text or patterns
- Token usage matters (text is smaller than images)
- You need to process the output programmatically
**Use `screenshotWithAccessibilityLabels` when:**
- Page has complex visual layout (grids, galleries, dashboards, maps)
- Spatial position matters (e.g., "first image", "top-left button")
- DOM order doesn't match visual order
@@ -487,9 +509,9 @@ page.locator('button').nth(2).click()
If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`:
```js
await page.locator('button').first().click() // first match
await page.locator('.item').last().click() // last match
await page.locator('li').nth(3).click() // 4th item (0-indexed)
await page.locator('button').first().click() // first match
await page.locator('.item').last().click() // last match
await page.locator('li').nth(3).click() // 4th item (0-indexed)
```
## working with pages
@@ -504,8 +526,8 @@ On your very first execute call, reuse an existing empty tab or create a new one
// Reuse an empty about:blank tab if available, otherwise create a new one.
// IMPORTANT: always navigate immediately in the same call to avoid another
// agent grabbing the same about:blank tab between execute calls.
state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
await state.myPage.goto('https://example.com');
state.myPage = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.myPage.goto('https://example.com')
// Use state.myPage for ALL subsequent operations
```
@@ -515,9 +537,9 @@ The user may close your page by accident (e.g., closing a tab in Chrome). Always
```js
if (!state.myPage || state.myPage.isClosed()) {
state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
state.myPage = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
}
await state.myPage.goto('https://example.com');
await state.myPage.goto('https://example.com')
```
**Use an existing page only when the user asks:**
@@ -525,16 +547,16 @@ await state.myPage.goto('https://example.com');
Only use a page from `context.pages()` if the user explicitly asks you to control a specific tab they already opened (e.g., they're logged into an app). Find it by URL pattern and store it in state:
```js
const pages = context.pages().filter(x => x.url().includes('myapp.com'));
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.');
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`);
state.targetPage = pages[0];
const pages = context.pages().filter((x) => x.url().includes('myapp.com'))
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.')
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
state.targetPage = pages[0]
```
**List all available pages:**
```js
context.pages().map(p => p.url())
context.pages().map((p) => p.url())
```
## navigation
@@ -542,8 +564,8 @@ context.pages().map(p => p.url())
**Use `domcontentloaded`** for `page.goto()`:
```js
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await waitForPageLoad({ page, timeout: 5000 });
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page, timeout: 5000 })
```
## common patterns
@@ -556,9 +578,9 @@ await waitForPageLoad({ page, timeout: 5000 });
// GOOD: fetch inside page.evaluate uses browser's full session
const data = await page.evaluate(async (url) => {
const resp = await fetch(url);
return await resp.text();
}, 'https://example.com/protected/resource');
const resp = await fetch(url)
return await resp.text()
}, 'https://example.com/protected/resource')
```
**Downloading large data** - console output truncates large strings. Trigger a browser download instead:
@@ -566,18 +588,19 @@ const data = await page.evaluate(async (url) => {
```js
// Fetch protected data and trigger download to user's Downloads folder
await page.evaluate(async (url) => {
const resp = await fetch(url);
const data = await resp.text();
const blob = new Blob([data], { type: 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'data.json';
a.click();
}, 'https://example.com/protected/large-file');
const resp = await fetch(url)
const data = await resp.text()
const blob = new Blob([data], { type: 'application/octet-stream' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'data.json'
a.click()
}, 'https://example.com/protected/large-file')
// File saves to ~/Downloads - read it from there
```
**Avoid permission-gated browser APIs** - some APIs require user permission prompts or special browser flags. These often fail silently or hang. Examples to avoid:
- `navigator.clipboard.writeText()` - requires permission
- Multiple concurrent downloads - browser may block
- `window.showSaveFilePicker()` - requires user gesture
@@ -588,37 +611,40 @@ Instead, use simpler alternatives (single download via `a.click()`, store data i
**Links that open new tabs** - playwriter cannot control popup windows opened via `window.open`. Use cmd+click to open in a controllable new tab instead (see mistake #9 above for a full example):
```js
await page.locator('a[target=_blank]').click({ modifiers: ['Meta'] });
await page.waitForTimeout(1000);
const pages = context.pages();
const newTab = pages[pages.length - 1];
console.log('New tab URL:', newTab.url());
await page.locator('a[target=_blank]').click({ modifiers: ['Meta'] })
await page.waitForTimeout(1000)
const pages = context.pages()
const newTab = pages[pages.length - 1]
console.log('New tab URL:', newTab.url())
```
**Downloads** - capture and save:
```js
const [download] = await Promise.all([page.waitForEvent('download'), page.click('button.download')]);
await download.saveAs(`./${download.suggestedFilename()}`);
const [download] = await Promise.all([page.waitForEvent('download'), page.click('button.download')])
await download.saveAs(`./${download.suggestedFilename()}`)
```
**iFrames** - two approaches depending on what you need:
```js
// frameLocator: for chaining locator operations (click, fill, etc.)
const frame = page.frameLocator('#my-iframe');
await frame.locator('button').click();
const frame = page.frameLocator('#my-iframe')
await frame.locator('button').click()
// contentFrame: returns a Frame object, needed for snapshot({ frame })
const frame2 = await page.locator('iframe').contentFrame();
const frame2 = await page.locator('iframe').contentFrame()
await snapshot({ frame: frame2 })
```
**Dialogs** - handle alerts/confirms/prompts:
```js
page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); });
await page.click('button.trigger-alert');
page.on('dialog', async (dialog) => {
console.log(dialog.message())
await dialog.accept()
})
await page.click('button.trigger-alert')
```
## utility functions
@@ -645,6 +671,7 @@ const fullHtml = await getCleanHTML({ locator: page, showDiffSinceLastCall: fals
```
**Parameters:**
- `locator` - Playwright Locator or Page to get HTML from
- `search` - string/regex to filter results (returns first 10 matching lines with 5 lines context)
- `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full HTML.
@@ -652,12 +679,14 @@ const fullHtml = await getCleanHTML({ locator: page, showDiffSinceLastCall: fals
**HTML processing:**
The function cleans HTML for compact, readable output:
- **Removes tags**: script, style, link, meta, noscript, svg, head
- **Unwraps nested wrappers**: Empty divs/spans with no attributes that only wrap a single child are collapsed (e.g., `<div><div><div><p>text</p></div></div></div>` → `<div><p>text</p></div>`)
- **Removes empty elements**: Elements with no attributes and no content are removed
- **Truncates long values**: Attribute values >200 chars and text content >500 chars are truncated
**Attributes kept (summary):**
- Common semantic and ARIA attributes (e.g., `href`, `name`, `type`, `aria-*`)
- All `data-*` test attributes
- Frequently used test IDs and special attributes (e.g., `testid`, `qa`, `e2e`, `vimium-label`)
@@ -672,6 +701,7 @@ const matches = await getPageMarkdown({ page, search: /API/i }) // search withi
```
**Output format:**
```
# Article Title
@@ -683,11 +713,13 @@ The main article content as plain text, with paragraphs preserved...
```
**Parameters:**
- `page` - Playwright Page to extract content from
- `search` - string/regex to filter content (returns first 10 matching lines with 5 lines context)
- `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full content.
**Use cases:**
- Extract article text for LLM processing without HTML noise
- Get readable content from news sites, blogs, documentation
- Compare content changes after interactions
@@ -702,46 +734,50 @@ await waitForPageLoad({ page, timeout?, pollInterval?, minWait? })
**getCDPSession** - send raw CDP commands:
```js
const cdp = await getCDPSession({ page });
const metrics = await cdp.send('Page.getLayoutMetrics');
const cdp = await getCDPSession({ page })
const metrics = await cdp.send('Page.getLayoutMetrics')
```
**getLocatorStringForElement** - get stable Playwright selector from an element:
```js
const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]'));
const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]'))
// => "getByRole('button', { name: 'Save' })"
```
**getReactSource** - get React component source location (dev mode only):
```js
const source = await getReactSource({ locator: page.locator('[data-testid="submit-btn"]') });
const source = await getReactSource({ locator: page.locator('[data-testid="submit-btn"]') })
// => { fileName, lineNumber, columnNumber, componentName }
```
**getStylesForLocator** - inspect CSS styles applied to an element, like browser DevTools "Styles" panel. Useful for debugging styling issues, finding where a CSS property is defined (file:line), and checking inherited styles. Returns selector, source location, and declarations for each matching rule. ALWAYS fetch `https://playwriter.dev/resources/styles-api.md` first with curl or webfetch tool.
```js
const styles = await getStylesForLocator({ locator: page.locator('.btn'), cdp: await getCDPSession({ page }) });
console.log(formatStylesAsText(styles));
const styles = await getStylesForLocator({ locator: page.locator('.btn'), cdp: await getCDPSession({ page }) })
console.log(formatStylesAsText(styles))
```
**createDebugger** - set breakpoints, step through code, inspect variables at runtime. Useful for debugging issues that only reproduce in browser, understanding code flow, and inspecting state at specific points. Can pause on exceptions, evaluate expressions in scope, and blackbox framework code. ALWAYS fetch `https://playwriter.dev/resources/debugger-api.md` first.
```js
const cdp = await getCDPSession({ page }); const dbg = createDebugger({ cdp }); await dbg.enable();
const scripts = await dbg.listScripts({ search: 'app' });
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 });
const cdp = await getCDPSession({ page })
const dbg = createDebugger({ cdp })
await dbg.enable()
const scripts = await dbg.listScripts({ search: 'app' })
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 })
// when paused: dbg.inspectLocalVariables(), dbg.stepOver(), dbg.resume()
```
**createEditor** - view and live-edit page scripts and CSS at runtime. Edits are in-memory (persist until reload). Useful for testing quick fixes, searching page scripts with grep, and toggling debug flags. ALWAYS read `https://playwriter.dev/resources/editor-api.md` first.
```js
const cdp = await getCDPSession({ page }); const editor = createEditor({ cdp }); await editor.enable();
const matches = await editor.grep({ regex: /console\.log/ });
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' });
const cdp = await getCDPSession({ page })
const editor = createEditor({ cdp })
await editor.enable()
const matches = await editor.grep({ regex: /console\.log/ })
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' })
```
**screenshotWithAccessibilityLabels** - take a screenshot with Vimium-style visual labels overlaid on interactive elements. Shows labels, captures screenshot, then removes labels. The image and accessibility snapshot are automatically included in the response. Can be called multiple times to capture multiple screenshots. Use a timeout of **20 seconds** for complex pages.
@@ -749,15 +785,15 @@ await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString:
Prefer this for pages with grids, image galleries, maps, or complex visual layouts where spatial position matters. For simple text-heavy pages, `snapshot` with search is faster and uses fewer tokens.
```js
await screenshotWithAccessibilityLabels({ page });
await screenshotWithAccessibilityLabels({ page })
// Image and accessibility snapshot are automatically included in response
// Use refs from snapshot to interact with elements
await page.locator('[id="submit-btn"]').click();
await page.locator('[id="submit-btn"]').click()
// Can take multiple screenshots in one execution
await screenshotWithAccessibilityLabels({ page });
await page.click('button');
await screenshotWithAccessibilityLabels({ page });
await screenshotWithAccessibilityLabels({ page })
await page.click('button')
await screenshotWithAccessibilityLabels({ page })
// Both images are included in the response
```
@@ -769,31 +805,32 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
```js
// Start recording - outputPath must be specified upfront
await startRecording({
page,
await startRecording({
page,
outputPath: './recording.mp4',
frameRate: 30, // default: 30
audio: false, // default: false (tab audio)
videoBitsPerSecond: 2500000 // 2.5 Mbps
});
frameRate: 30, // default: 30
audio: false, // default: false (tab audio)
videoBitsPerSecond: 2500000, // 2.5 Mbps
})
// Navigate around - recording continues!
await page.click('a');
await page.waitForLoadState('domcontentloaded');
await page.goBack();
await page.click('a')
await page.waitForLoadState('domcontentloaded')
await page.goBack()
// Stop and get result
const { path, duration, size } = await stopRecording({ page });
console.log(`Saved ${size} bytes, duration: ${duration}ms`);
const { path, duration, size } = await stopRecording({ page })
console.log(`Saved ${size} bytes, duration: ${duration}ms`)
```
Additional recording utilities:
```js
// Check if recording is active
const { isRecording, startedAt } = await isRecording({ page });
const { isRecording, startedAt } = await isRecording({ page })
// Cancel recording without saving
await cancelRecording({ page });
await cancelRecording({ page })
```
**Key difference from getDisplayMedia**: This approach uses `chrome.tabCapture` which runs in the extension context, not the page. The recording persists across navigations because the extension holds the `MediaRecorder`, not the page's JavaScript context.
@@ -803,8 +840,8 @@ await cancelRecording({ page });
Users can right-click → "Copy Playwriter Element Reference" to store elements in `globalThis.playwriterPinnedElem1` (increments for each pin). The reference is copied to clipboard:
```js
const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1);
await el.click();
const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
await el.click()
```
## taking screenshots
@@ -812,7 +849,7 @@ await el.click();
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
```js
await page.screenshot({ path: 'shot.png', scale: 'css' });
await page.screenshot({ path: 'shot.png', scale: 'css' })
```
If you want to read back the image file into context make sure to resize it first, scaling down the image to make sure max size is 1500px. for example with `sips --resampleHeightWidthMax 1500 input.png --out output.png` on macOS.
@@ -822,14 +859,14 @@ If you want to read back the image file into context make sure to resize it firs
Code inside `page.evaluate()` runs in the browser - use plain JavaScript only, no TypeScript syntax. Return values and log outside (console.log inside evaluate runs in browser, not visible):
```js
const title = await page.evaluate(() => document.title);
console.log('Title:', title);
const title = await page.evaluate(() => document.title)
console.log('Title:', title)
const info = await page.evaluate(() => ({
url: location.href,
buttons: document.querySelectorAll('button').length,
}));
console.log(info);
url: location.href,
buttons: document.querySelectorAll('button').length,
}))
console.log(info)
```
## loading files
@@ -837,7 +874,9 @@ console.log(info);
Fill inputs with file content:
```js
const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'utf-8'); await page.locator('textarea').fill(content);
const fs = require('node:fs')
const content = fs.readFileSync('./data.txt', 'utf-8')
await page.locator('textarea').fill(content)
```
## network interception
@@ -845,31 +884,46 @@ const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'ut
For scraping or reverse-engineering APIs, intercept network requests instead of scrolling DOM. Store in `state` to analyze across calls:
```js
state.requests = []; state.responses = [];
page.on('request', req => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }); });
page.on('response', async res => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }); } catch {} } });
state.requests = []
state.responses = []
page.on('request', (req) => {
if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() })
})
page.on('response', async (res) => {
if (res.url().includes('/api/')) {
try {
state.responses.push({ url: res.url(), status: res.status(), body: await res.json() })
} catch {}
}
})
```
Then trigger actions (scroll, click, navigate) and analyze captured data:
```js
console.log('Captured', state.responses.length, 'API calls');
state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)));
console.log('Captured', state.responses.length, 'API calls')
state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
```
Inspect a specific response to understand schema:
```js
const resp = state.responses.find(r => r.url.includes('users'));
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000));
const resp = state.responses.find((r) => r.url.includes('users'))
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
```
Replay API directly (useful for pagination):
```js
const { url, headers } = state.requests.find(r => r.url.includes('feed'));
const data = await page.evaluate(async ({ url, headers }) => { const res = await fetch(url, { headers }); return res.json(); }, { url, headers });
console.log(data);
const { url, headers } = state.requests.find((r) => r.url.includes('feed'))
const data = await page.evaluate(
async ({ url, headers }) => {
const res = await fetch(url, { headers })
return res.json()
},
{ url, headers },
)
console.log(data)
```
Clean up listeners when done: `page.removeAllListeners('request'); page.removeAllListeners('response');`
@@ -881,38 +935,39 @@ When debugging why a web app isn't working (e.g., content not rendering, API err
**1. Console logs** — use `getLatestLogs` to check for errors:
```js
const errors = await getLatestLogs({ page, search: /error|fail/i, count: 20 });
const appLogs = await getLatestLogs({ page, search: /myComponent|state/i });
const errors = await getLatestLogs({ page, search: /error|fail/i, count: 20 })
const appLogs = await getLatestLogs({ page, search: /myComponent|state/i })
```
**2. DOM inspection via evaluate** — check content directly without screenshots:
```js
const info = await page.evaluate(() => {
const msgs = document.querySelectorAll('.message');
return Array.from(msgs).map(m => ({
const msgs = document.querySelectorAll('.message')
return Array.from(msgs).map((m) => ({
text: m.textContent?.slice(0, 200),
visible: m.offsetHeight > 0,
}));
});
console.log(JSON.stringify(info, null, 2));
}))
})
console.log(JSON.stringify(info, null, 2))
```
**3. Combine snapshot + logs for full picture:**
```js
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
await page.keyboard.press('Enter')
await page.waitForTimeout(2000)
const snap = await snapshot({ page, search: /dialog|error|message/ });
const logs = await getLatestLogs({ page, search: /error/i, count: 10 });
console.log('UI:', snap);
console.log('Logs:', logs);
const snap = await snapshot({ page, search: /dialog|error|message/ })
const logs = await getLatestLogs({ page, search: /error/i, count: 10 })
console.log('UI:', snap)
console.log('Logs:', logs)
```
## capabilities
Examples of what playwriter can do:
- Monitor console logs while user reproduces a bug
- Intercept network requests to reverse-engineer APIs and build SDKs
- Scrape data by replaying paginated API calls instead of scrolling DOM
@@ -922,7 +977,6 @@ Examples of what playwriter can do:
- Handle popups, downloads, iframes, and dialog boxes
- Record videos of browser sessions that survive page navigation
## computer use
Playwriter provides the same browser control as Anthropic's `computer_20250124` tool and the Claude Chrome extension, using Playwright APIs instead of screenshot-based coordinate clicking. No computer use beta needed.
@@ -936,21 +990,24 @@ This section covers low-level mouse/keyboard APIs not documented elsewhere. For
await page.locator('button[name="Submit"]').click()
await page.locator('text=Login').click({ button: 'right' })
await page.locator('text=Login').dblclick()
await page.locator('a').first().click({ modifiers: ['Meta'] }) // cmd+click opens new tab
await page
.locator('a')
.first()
.click({ modifiers: ['Meta'] }) // cmd+click opens new tab
// By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets)
await page.mouse.click(450, 320) // left click
await page.mouse.click(450, 320, { button: 'right' }) // right click
await page.mouse.dblclick(450, 320) // double click
await page.mouse.click(450, 320, { clickCount: 3 }) // triple click
await page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
await page.mouse.click(450, 320) // left click
await page.mouse.click(450, 320, { button: 'right' }) // right click
await page.mouse.dblclick(450, 320) // double click
await page.mouse.click(450, 320, { clickCount: 3 }) // triple click
await page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
```
### hover
```js
await page.locator('.tooltip-trigger').hover() // by locator (preferred)
await page.mouse.move(450, 320) // by coordinates
await page.locator('.tooltip-trigger').hover() // by locator (preferred)
await page.mouse.move(450, 320) // by coordinates
```
### scroll
@@ -960,17 +1017,19 @@ await page.mouse.move(450, 320) // by coordinates
await page.locator('#footer').scrollIntoViewIfNeeded()
// By pixel (for canvas, maps, infinite scroll)
await page.mouse.wheel(0, 300) // scroll down 300px
await page.mouse.wheel(0, -300) // scroll up
await page.mouse.wheel(300, 0) // scroll right
await page.mouse.wheel(-300, 0) // scroll left
await page.mouse.wheel(0, 300) // scroll down 300px
await page.mouse.wheel(0, -300) // scroll up
await page.mouse.wheel(300, 0) // scroll right
await page.mouse.wheel(-300, 0) // scroll left
// Scroll at a specific position
await page.mouse.move(450, 320)
await page.mouse.wheel(0, 500)
// Scroll inside a container
await page.locator('.scrollable-list').evaluate(el => { el.scrollTop += 500 })
await page.locator('.scrollable-list').evaluate((el) => {
el.scrollTop += 500
})
```
### drag
@@ -982,7 +1041,7 @@ await page.locator('#item').dragTo(page.locator('#target'))
// By coordinates (for canvas, sliders, custom drag targets)
await page.mouse.move(100, 200)
await page.mouse.down()
await page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
await page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
await page.mouse.up()
```
@@ -1018,12 +1077,12 @@ Playwriter supports [Ghost Browser](https://ghostbrowser.com/) for multi-identit
```js
// List identities and open tabs in different ones
const identities = await chrome.projects.getIdentitiesList();
await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id });
const identities = await chrome.projects.getIdentitiesList()
await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id })
// Assign proxies per tab or identity
const proxies = await chrome.ghostProxies.getList();
await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id);
const proxies = await chrome.ghostProxies.getList()
await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id)
```
For complete API reference with all methods, types, and examples, read:
+333 -358
View File
@@ -2,31 +2,31 @@
## Types
```ts
import type { ICDPSession } from './cdp-session.js';
````ts
import type { ICDPSession } from './cdp-session.js'
export interface BreakpointInfo {
id: string;
file: string;
line: number;
id: string
file: string
line: number
}
export interface LocationInfo {
url: string;
lineNumber: number;
columnNumber: number;
callstack: Array<{
functionName: string;
url: string;
lineNumber: number;
columnNumber: number;
}>;
sourceContext: string;
url: string
lineNumber: number
columnNumber: number
callstack: Array<{
functionName: string
url: string
lineNumber: number
columnNumber: number
}>
sourceContext: string
}
export interface EvaluateResult {
value: unknown;
value: unknown
}
export interface ScriptInfo {
scriptId: string;
url: string;
scriptId: string
url: string
}
/**
* A class for debugging JavaScript code via Chrome DevTools Protocol.
@@ -45,345 +45,321 @@ export interface ScriptInfo {
* ```
*/
export declare class Debugger {
private cdp;
private debuggerEnabled;
private paused;
private currentCallFrames;
private breakpoints;
private scripts;
private xhrBreakpoints;
private blackboxPatterns;
/**
* Creates a new Debugger instance.
*
* @param options - Configuration options
* @param options.cdp - A CDPSession instance for sending CDP commands (works with both
* our CDPSession and Playwright's CDPSession)
*
* @example
* ```ts
* const cdp = await getCDPSessionForPage({ page, wsUrl })
* const dbg = new Debugger({ cdp })
* ```
*/
constructor({ cdp }: {
cdp: ICDPSession;
});
private setupEventListeners;
/**
* Enables the debugger and runtime domains. Called automatically by other methods.
* Also resumes execution if the target was started with --inspect-brk.
*
* @example
* ```ts
* await dbg.enable()
* ```
*/
enable(): Promise<void>;
/**
* Sets a breakpoint at a specified URL and line number.
* Use the URL from listScripts() to find available scripts.
*
* @param options - Breakpoint options
* @param options.file - Script URL (e.g. https://example.com/app.js)
* @param options.line - Line number (1-based)
* @param options.condition - Optional JS expression; only pause when it evaluates to true
* @returns The breakpoint ID for later removal
*
* @example
* ```ts
* const id = await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 })
* // later:
* await dbg.deleteBreakpoint({ breakpointId: id })
*
* // Conditional breakpoint - only pause when userId is 123
* await dbg.setBreakpoint({
* file: 'https://example.com/app.js',
* line: 42,
* condition: 'userId === 123'
* })
* ```
*/
setBreakpoint({ file, line, condition }: {
file: string;
line: number;
condition?: string;
}): Promise<string>;
/**
* Removes a breakpoint by its ID.
*
* @param options - Options
* @param options.breakpointId - The breakpoint ID returned by setBreakpoint
*
* @example
* ```ts
* await dbg.deleteBreakpoint({ breakpointId: 'bp-123' })
* ```
*/
deleteBreakpoint({ breakpointId }: {
breakpointId: string;
}): Promise<void>;
/**
* Returns a list of all active breakpoints set by this debugger instance.
*
* @returns Array of breakpoint info objects
*
* @example
* ```ts
* const breakpoints = dbg.listBreakpoints()
* // [{ id: 'bp-123', file: 'https://example.com/index.js', line: 42 }]
* ```
*/
listBreakpoints(): BreakpointInfo[];
/**
* Inspects local variables in the current call frame.
* Must be paused at a breakpoint. String values over 1000 chars are truncated.
* Use evaluate() for full control over reading specific values.
*
* @returns Record of variable names to values
* @throws Error if not paused or no active call frames
*
* @example
* ```ts
* const vars = await dbg.inspectLocalVariables()
* // { myVar: 'hello', count: 42 }
* ```
*/
inspectLocalVariables(): Promise<Record<string, unknown>>;
/**
* Returns global lexical scope variable names.
*
* @returns Array of global variable names
*
* @example
* ```ts
* const globals = await dbg.inspectGlobalVariables()
* // ['myGlobal', 'CONFIG']
* ```
*/
inspectGlobalVariables(): Promise<string[]>;
/**
* Evaluates a JavaScript expression and returns the result.
* When paused at a breakpoint, evaluates in the current stack frame scope,
* allowing access to local variables. Otherwise evaluates in global scope.
* Values are not truncated, use this for full control over reading specific variables.
*
* @param options - Options
* @param options.expression - JavaScript expression to evaluate
* @returns The result value
*
* @example
* ```ts
* // When paused, can access local variables:
* const result = await dbg.evaluate({ expression: 'localVar + 1' })
*
* // Read a large string that would be truncated in inspectLocalVariables:
* const full = await dbg.evaluate({ expression: 'largeStringVar' })
* ```
*/
evaluate({ expression }: {
expression: string;
}): Promise<EvaluateResult>;
/**
* Gets the current execution location when paused at a breakpoint.
* Includes the call stack and surrounding source code for context.
*
* @returns Location info with URL, line number, call stack, and source context
* @throws Error if debugger is not paused
*
* @example
* ```ts
* const location = await dbg.getLocation()
* console.log(location.url) // 'https://example.com/src/index.js'
* console.log(location.lineNumber) // 42
* console.log(location.callstack) // [{ functionName: 'handleRequest', ... }]
* console.log(location.sourceContext)
* // ' 40: function handleRequest(req) {
* // 41: const data = req.body
* // > 42: processData(data)
* // 43: }'
* ```
*/
getLocation(): Promise<LocationInfo>;
/**
* Steps over to the next line of code, not entering function calls.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.stepOver()
* const newLocation = await dbg.getLocation()
* ```
*/
stepOver(): Promise<void>;
/**
* Steps into a function call on the current line.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.stepInto()
* const location = await dbg.getLocation()
* // now inside the called function
* ```
*/
stepInto(): Promise<void>;
/**
* Steps out of the current function, returning to the caller.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.stepOut()
* const location = await dbg.getLocation()
* // back in the calling function
* ```
*/
stepOut(): Promise<void>;
/**
* Resumes code execution until the next breakpoint or completion.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.resume()
* // execution continues
* ```
*/
resume(): Promise<void>;
/**
* Returns whether the debugger is currently paused at a breakpoint.
*
* @returns true if paused, false otherwise
*
* @example
* ```ts
* if (dbg.isPaused()) {
* const vars = await dbg.inspectLocalVariables()
* }
* ```
*/
isPaused(): boolean;
/**
* Configures the debugger to pause on exceptions.
*
* @param options - Options
* @param options.state - When to pause: 'none' (never), 'uncaught' (only uncaught), or 'all' (all exceptions)
*
* @example
* ```ts
* // Pause only on uncaught exceptions
* await dbg.setPauseOnExceptions({ state: 'uncaught' })
*
* // Pause on all exceptions (caught and uncaught)
* await dbg.setPauseOnExceptions({ state: 'all' })
*
* // Disable pausing on exceptions
* await dbg.setPauseOnExceptions({ state: 'none' })
* ```
*/
setPauseOnExceptions({ state }: {
state: 'none' | 'uncaught' | 'all';
}): Promise<void>;
/**
* Lists available scripts where breakpoints can be set.
* Automatically enables the debugger if not already enabled.
*
* @param options - Options
* @param options.search - Optional string to filter scripts by URL (case-insensitive)
* @returns Array of up to 20 matching scripts with scriptId and url
*
* @example
* ```ts
* // List all scripts
* const scripts = await dbg.listScripts()
* // [{ scriptId: '1', url: 'https://example.com/app.js' }, ...]
*
* // Search for specific files
* const handlers = await dbg.listScripts({ search: 'handler' })
* // [{ scriptId: '5', url: 'https://example.com/handlers.js' }]
* ```
*/
listScripts({ search }?: {
search?: string;
}): Promise<ScriptInfo[]>;
setXHRBreakpoint({ url }: {
url: string;
}): Promise<void>;
removeXHRBreakpoint({ url }: {
url: string;
}): Promise<void>;
listXHRBreakpoints(): string[];
/**
* Sets regex patterns for scripts to blackbox (skip when stepping).
* Blackboxed scripts are hidden from the call stack and stepped over automatically.
* Useful for ignoring framework/library code during debugging.
*
* @param options - Options
* @param options.patterns - Array of regex patterns to match script URLs
*
* @example
* ```ts
* // Skip all node_modules
* await dbg.setBlackboxPatterns({ patterns: ['node_modules'] })
*
* // Skip React and other frameworks
* await dbg.setBlackboxPatterns({
* patterns: [
* 'node_modules/react',
* 'node_modules/react-dom',
* 'node_modules/next',
* 'webpack://',
* ]
* })
*
* // Skip all third-party scripts
* await dbg.setBlackboxPatterns({ patterns: ['^https://cdn\\.'] })
*
* // Clear all blackbox patterns
* await dbg.setBlackboxPatterns({ patterns: [] })
* ```
*/
setBlackboxPatterns({ patterns }: {
patterns: string[];
}): Promise<void>;
/**
* Adds a single regex pattern to the blackbox list.
*
* @param options - Options
* @param options.pattern - Regex pattern to match script URLs
*
* @example
* ```ts
* await dbg.addBlackboxPattern({ pattern: 'node_modules/lodash' })
* await dbg.addBlackboxPattern({ pattern: 'node_modules/axios' })
* ```
*/
addBlackboxPattern({ pattern }: {
pattern: string;
}): Promise<void>;
/**
* Removes a pattern from the blackbox list.
*
* @param options - Options
* @param options.pattern - The exact pattern string to remove
*/
removeBlackboxPattern({ pattern }: {
pattern: string;
}): Promise<void>;
/**
* Returns the current list of blackbox patterns.
*/
listBlackboxPatterns(): string[];
private truncateValue;
private formatPropertyValue;
private processRemoteObject;
private cdp
private debuggerEnabled
private paused
private currentCallFrames
private breakpoints
private scripts
private xhrBreakpoints
private blackboxPatterns
/**
* Creates a new Debugger instance.
*
* @param options - Configuration options
* @param options.cdp - A CDPSession instance for sending CDP commands (works with both
* our CDPSession and Playwright's CDPSession)
*
* @example
* ```ts
* const cdp = await getCDPSessionForPage({ page, wsUrl })
* const dbg = new Debugger({ cdp })
* ```
*/
constructor({ cdp }: { cdp: ICDPSession })
private setupEventListeners
/**
* Enables the debugger and runtime domains. Called automatically by other methods.
* Also resumes execution if the target was started with --inspect-brk.
*
* @example
* ```ts
* await dbg.enable()
* ```
*/
enable(): Promise<void>
/**
* Sets a breakpoint at a specified URL and line number.
* Use the URL from listScripts() to find available scripts.
*
* @param options - Breakpoint options
* @param options.file - Script URL (e.g. https://example.com/app.js)
* @param options.line - Line number (1-based)
* @param options.condition - Optional JS expression; only pause when it evaluates to true
* @returns The breakpoint ID for later removal
*
* @example
* ```ts
* const id = await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 })
* // later:
* await dbg.deleteBreakpoint({ breakpointId: id })
*
* // Conditional breakpoint - only pause when userId is 123
* await dbg.setBreakpoint({
* file: 'https://example.com/app.js',
* line: 42,
* condition: 'userId === 123'
* })
* ```
*/
setBreakpoint({ file, line, condition }: { file: string; line: number; condition?: string }): Promise<string>
/**
* Removes a breakpoint by its ID.
*
* @param options - Options
* @param options.breakpointId - The breakpoint ID returned by setBreakpoint
*
* @example
* ```ts
* await dbg.deleteBreakpoint({ breakpointId: 'bp-123' })
* ```
*/
deleteBreakpoint({ breakpointId }: { breakpointId: string }): Promise<void>
/**
* Returns a list of all active breakpoints set by this debugger instance.
*
* @returns Array of breakpoint info objects
*
* @example
* ```ts
* const breakpoints = dbg.listBreakpoints()
* // [{ id: 'bp-123', file: 'https://example.com/index.js', line: 42 }]
* ```
*/
listBreakpoints(): BreakpointInfo[]
/**
* Inspects local variables in the current call frame.
* Must be paused at a breakpoint. String values over 1000 chars are truncated.
* Use evaluate() for full control over reading specific values.
*
* @returns Record of variable names to values
* @throws Error if not paused or no active call frames
*
* @example
* ```ts
* const vars = await dbg.inspectLocalVariables()
* // { myVar: 'hello', count: 42 }
* ```
*/
inspectLocalVariables(): Promise<Record<string, unknown>>
/**
* Returns global lexical scope variable names.
*
* @returns Array of global variable names
*
* @example
* ```ts
* const globals = await dbg.inspectGlobalVariables()
* // ['myGlobal', 'CONFIG']
* ```
*/
inspectGlobalVariables(): Promise<string[]>
/**
* Evaluates a JavaScript expression and returns the result.
* When paused at a breakpoint, evaluates in the current stack frame scope,
* allowing access to local variables. Otherwise evaluates in global scope.
* Values are not truncated, use this for full control over reading specific variables.
*
* @param options - Options
* @param options.expression - JavaScript expression to evaluate
* @returns The result value
*
* @example
* ```ts
* // When paused, can access local variables:
* const result = await dbg.evaluate({ expression: 'localVar + 1' })
*
* // Read a large string that would be truncated in inspectLocalVariables:
* const full = await dbg.evaluate({ expression: 'largeStringVar' })
* ```
*/
evaluate({ expression }: { expression: string }): Promise<EvaluateResult>
/**
* Gets the current execution location when paused at a breakpoint.
* Includes the call stack and surrounding source code for context.
*
* @returns Location info with URL, line number, call stack, and source context
* @throws Error if debugger is not paused
*
* @example
* ```ts
* const location = await dbg.getLocation()
* console.log(location.url) // 'https://example.com/src/index.js'
* console.log(location.lineNumber) // 42
* console.log(location.callstack) // [{ functionName: 'handleRequest', ... }]
* console.log(location.sourceContext)
* // ' 40: function handleRequest(req) {
* // 41: const data = req.body
* // > 42: processData(data)
* // 43: }'
* ```
*/
getLocation(): Promise<LocationInfo>
/**
* Steps over to the next line of code, not entering function calls.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.stepOver()
* const newLocation = await dbg.getLocation()
* ```
*/
stepOver(): Promise<void>
/**
* Steps into a function call on the current line.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.stepInto()
* const location = await dbg.getLocation()
* // now inside the called function
* ```
*/
stepInto(): Promise<void>
/**
* Steps out of the current function, returning to the caller.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.stepOut()
* const location = await dbg.getLocation()
* // back in the calling function
* ```
*/
stepOut(): Promise<void>
/**
* Resumes code execution until the next breakpoint or completion.
*
* @throws Error if debugger is not paused
*
* @example
* ```ts
* await dbg.resume()
* // execution continues
* ```
*/
resume(): Promise<void>
/**
* Returns whether the debugger is currently paused at a breakpoint.
*
* @returns true if paused, false otherwise
*
* @example
* ```ts
* if (dbg.isPaused()) {
* const vars = await dbg.inspectLocalVariables()
* }
* ```
*/
isPaused(): boolean
/**
* Configures the debugger to pause on exceptions.
*
* @param options - Options
* @param options.state - When to pause: 'none' (never), 'uncaught' (only uncaught), or 'all' (all exceptions)
*
* @example
* ```ts
* // Pause only on uncaught exceptions
* await dbg.setPauseOnExceptions({ state: 'uncaught' })
*
* // Pause on all exceptions (caught and uncaught)
* await dbg.setPauseOnExceptions({ state: 'all' })
*
* // Disable pausing on exceptions
* await dbg.setPauseOnExceptions({ state: 'none' })
* ```
*/
setPauseOnExceptions({ state }: { state: 'none' | 'uncaught' | 'all' }): Promise<void>
/**
* Lists available scripts where breakpoints can be set.
* Automatically enables the debugger if not already enabled.
*
* @param options - Options
* @param options.search - Optional string to filter scripts by URL (case-insensitive)
* @returns Array of up to 20 matching scripts with scriptId and url
*
* @example
* ```ts
* // List all scripts
* const scripts = await dbg.listScripts()
* // [{ scriptId: '1', url: 'https://example.com/app.js' }, ...]
*
* // Search for specific files
* const handlers = await dbg.listScripts({ search: 'handler' })
* // [{ scriptId: '5', url: 'https://example.com/handlers.js' }]
* ```
*/
listScripts({ search }?: { search?: string }): Promise<ScriptInfo[]>
setXHRBreakpoint({ url }: { url: string }): Promise<void>
removeXHRBreakpoint({ url }: { url: string }): Promise<void>
listXHRBreakpoints(): string[]
/**
* Sets regex patterns for scripts to blackbox (skip when stepping).
* Blackboxed scripts are hidden from the call stack and stepped over automatically.
* Useful for ignoring framework/library code during debugging.
*
* @param options - Options
* @param options.patterns - Array of regex patterns to match script URLs
*
* @example
* ```ts
* // Skip all node_modules
* await dbg.setBlackboxPatterns({ patterns: ['node_modules'] })
*
* // Skip React and other frameworks
* await dbg.setBlackboxPatterns({
* patterns: [
* 'node_modules/react',
* 'node_modules/react-dom',
* 'node_modules/next',
* 'webpack://',
* ]
* })
*
* // Skip all third-party scripts
* await dbg.setBlackboxPatterns({ patterns: ['^https://cdn\\.'] })
*
* // Clear all blackbox patterns
* await dbg.setBlackboxPatterns({ patterns: [] })
* ```
*/
setBlackboxPatterns({ patterns }: { patterns: string[] }): Promise<void>
/**
* Adds a single regex pattern to the blackbox list.
*
* @param options - Options
* @param options.pattern - Regex pattern to match script URLs
*
* @example
* ```ts
* await dbg.addBlackboxPattern({ pattern: 'node_modules/lodash' })
* await dbg.addBlackboxPattern({ pattern: 'node_modules/axios' })
* ```
*/
addBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
/**
* Removes a pattern from the blackbox list.
*
* @param options - Options
* @param options.pattern - The exact pattern string to remove
*/
removeBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
/**
* Returns the current list of blackbox patterns.
*/
listBlackboxPatterns(): string[]
private truncateValue
private formatPropertyValue
private processRemoteObject
}
```
````
## Examples
@@ -454,5 +430,4 @@ async function cleanupBreakpoints() {
}
export { listScriptsAndSetBreakpoint, inspectWhenPaused, stepThroughCode, cleanupBreakpoints }
```
```
+171 -172
View File
@@ -4,22 +4,22 @@ The Editor class provides a Claude Code-like interface for viewing and editing w
## Types
```ts
import type { ICDPSession } from './cdp-session.js';
````ts
import type { ICDPSession } from './cdp-session.js'
export interface ReadResult {
content: string;
totalLines: number;
startLine: number;
endLine: number;
content: string
totalLines: number
startLine: number
endLine: number
}
export interface SearchMatch {
url: string;
lineNumber: number;
lineContent: string;
url: string
lineNumber: number
lineContent: string
}
export interface EditResult {
success: boolean;
stackChanged?: boolean;
success: boolean
stackChanged?: boolean
}
/**
* A class for viewing and editing web page scripts via Chrome DevTools Protocol.
@@ -49,165 +49,155 @@ export interface EditResult {
* ```
*/
export declare class Editor {
private cdp;
private enabled;
private scripts;
private stylesheets;
private sourceCache;
constructor({ cdp }: {
cdp: ICDPSession;
});
private setupEventListeners;
/**
* Enables the editor. Must be called before other methods.
* Scripts are collected from Debugger.scriptParsed events.
* Reload the page after enabling to capture all scripts.
*/
enable(): Promise<void>;
private getIdByUrl;
/**
* Lists available script and stylesheet URLs. Use pattern to filter by regex.
* Automatically enables the editor if not already enabled.
*
* @param options - Options
* @param options.pattern - Optional regex to filter URLs
* @returns Array of URLs
*
* @example
* ```ts
* // List all scripts and stylesheets
* const urls = await editor.list()
*
* // List only JS files
* const jsFiles = await editor.list({ pattern: /\.js/ })
*
* // List only CSS files
* const cssFiles = await editor.list({ pattern: /\.css/ })
*
* // Search for specific scripts
* const appScripts = await editor.list({ pattern: /app/ })
* ```
*/
list({ pattern }?: {
pattern?: RegExp;
}): Promise<string[]>;
/**
* Reads a script or stylesheet's source code by URL.
* Returns line-numbered content like Claude Code's Read tool.
* For inline scripts, use the `inline://` URL from list() or grep().
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.offset - Line number to start from (0-based, default 0)
* @param options.limit - Number of lines to return (default 2000)
* @returns Content with line numbers, total lines, and range info
*
* @example
* ```ts
* // Read by URL
* const { content, totalLines } = await editor.read({
* url: 'https://example.com/app.js'
* })
*
* // Read a CSS file
* const { content } = await editor.read({ url: 'https://example.com/styles.css' })
*
* // Read lines 100-200
* const { content } = await editor.read({
* url: 'https://example.com/app.js',
* offset: 100,
* limit: 100
* })
* ```
*/
read({ url, offset, limit }: {
url: string;
offset?: number;
limit?: number;
}): Promise<ReadResult>;
private getSource;
/**
* Edits a script or stylesheet by replacing oldString with newString.
* Like Claude Code's Edit tool - performs exact string replacement.
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.oldString - Exact string to find and replace
* @param options.newString - Replacement string
* @param options.dryRun - If true, validate without applying (default false)
* @returns Result with success status
*
* @example
* ```ts
* // Replace a string in JS
* await editor.edit({
* url: 'https://example.com/app.js',
* oldString: 'const DEBUG = false',
* newString: 'const DEBUG = true'
* })
*
* // Edit CSS
* await editor.edit({
* url: 'https://example.com/styles.css',
* oldString: 'color: red',
* newString: 'color: blue'
* })
* ```
*/
edit({ url, oldString, newString, dryRun, }: {
url: string;
oldString: string;
newString: string;
dryRun?: boolean;
}): Promise<EditResult>;
private setSource;
/**
* Searches for a regex across all scripts and stylesheets.
* Like Claude Code's Grep tool - returns matching lines with context.
*
* @param options - Options
* @param options.regex - Regular expression to search for in file contents
* @param options.pattern - Optional regex to filter which URLs to search
* @returns Array of matches with url, line number, and line content
*
* @example
* ```ts
* // Search all scripts and stylesheets for "color"
* const matches = await editor.grep({ regex: /color/ })
*
* // Search only CSS files
* const matches = await editor.grep({
* regex: /background-color/,
* pattern: /\.css/
* })
*
* // Regex search for console methods in JS
* const matches = await editor.grep({
* regex: /console\.(log|error|warn)/,
* pattern: /\.js/
* })
* ```
*/
grep({ regex, pattern }: {
regex: RegExp;
pattern?: RegExp;
}): Promise<SearchMatch[]>;
/**
* Writes entire content to a script or stylesheet, replacing all existing code.
* Use with caution - prefer edit() for targeted changes.
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/
write({ url, content, dryRun }: {
url: string;
content: string;
dryRun?: boolean;
}): Promise<EditResult>;
private cdp
private enabled
private scripts
private stylesheets
private sourceCache
constructor({ cdp }: { cdp: ICDPSession })
private setupEventListeners
/**
* Enables the editor. Must be called before other methods.
* Scripts are collected from Debugger.scriptParsed events.
* Reload the page after enabling to capture all scripts.
*/
enable(): Promise<void>
private getIdByUrl
/**
* Lists available script and stylesheet URLs. Use pattern to filter by regex.
* Automatically enables the editor if not already enabled.
*
* @param options - Options
* @param options.pattern - Optional regex to filter URLs
* @returns Array of URLs
*
* @example
* ```ts
* // List all scripts and stylesheets
* const urls = await editor.list()
*
* // List only JS files
* const jsFiles = await editor.list({ pattern: /\.js/ })
*
* // List only CSS files
* const cssFiles = await editor.list({ pattern: /\.css/ })
*
* // Search for specific scripts
* const appScripts = await editor.list({ pattern: /app/ })
* ```
*/
list({ pattern }?: { pattern?: RegExp }): Promise<string[]>
/**
* Reads a script or stylesheet's source code by URL.
* Returns line-numbered content like Claude Code's Read tool.
* For inline scripts, use the `inline://` URL from list() or grep().
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.offset - Line number to start from (0-based, default 0)
* @param options.limit - Number of lines to return (default 2000)
* @returns Content with line numbers, total lines, and range info
*
* @example
* ```ts
* // Read by URL
* const { content, totalLines } = await editor.read({
* url: 'https://example.com/app.js'
* })
*
* // Read a CSS file
* const { content } = await editor.read({ url: 'https://example.com/styles.css' })
*
* // Read lines 100-200
* const { content } = await editor.read({
* url: 'https://example.com/app.js',
* offset: 100,
* limit: 100
* })
* ```
*/
read({ url, offset, limit }: { url: string; offset?: number; limit?: number }): Promise<ReadResult>
private getSource
/**
* Edits a script or stylesheet by replacing oldString with newString.
* Like Claude Code's Edit tool - performs exact string replacement.
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.oldString - Exact string to find and replace
* @param options.newString - Replacement string
* @param options.dryRun - If true, validate without applying (default false)
* @returns Result with success status
*
* @example
* ```ts
* // Replace a string in JS
* await editor.edit({
* url: 'https://example.com/app.js',
* oldString: 'const DEBUG = false',
* newString: 'const DEBUG = true'
* })
*
* // Edit CSS
* await editor.edit({
* url: 'https://example.com/styles.css',
* oldString: 'color: red',
* newString: 'color: blue'
* })
* ```
*/
edit({
url,
oldString,
newString,
dryRun,
}: {
url: string
oldString: string
newString: string
dryRun?: boolean
}): Promise<EditResult>
private setSource
/**
* Searches for a regex across all scripts and stylesheets.
* Like Claude Code's Grep tool - returns matching lines with context.
*
* @param options - Options
* @param options.regex - Regular expression to search for in file contents
* @param options.pattern - Optional regex to filter which URLs to search
* @returns Array of matches with url, line number, and line content
*
* @example
* ```ts
* // Search all scripts and stylesheets for "color"
* const matches = await editor.grep({ regex: /color/ })
*
* // Search only CSS files
* const matches = await editor.grep({
* regex: /background-color/,
* pattern: /\.css/
* })
*
* // Regex search for console methods in JS
* const matches = await editor.grep({
* regex: /console\.(log|error|warn)/,
* pattern: /\.js/
* })
* ```
*/
grep({ regex, pattern }: { regex: RegExp; pattern?: RegExp }): Promise<SearchMatch[]>
/**
* Writes entire content to a script or stylesheet, replacing all existing code.
* Use with caution - prefer edit() for targeted changes.
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/
write({ url, content, dryRun }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult>
}
```
````
## Examples
@@ -359,6 +349,15 @@ async function searchStyles() {
console.log(matches)
}
export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript, readStylesheet, editStylesheet, searchStyles }
```
export {
listScripts,
readScript,
editScript,
searchScripts,
writeScript,
editInlineScript,
readStylesheet,
editStylesheet,
searchStyles,
}
```
+33 -23
View File
@@ -5,32 +5,36 @@ The getStylesForLocator function inspects CSS styles applied to an element, simi
## Types
```ts
import type { ICDPSession } from './cdp-session.js';
import type { Locator } from '@xmorse/playwright-core';
import type { ICDPSession } from './cdp-session.js'
import type { Locator } from '@xmorse/playwright-core'
export interface StyleSource {
url: string;
line: number;
column: number;
url: string
line: number
column: number
}
export type StyleDeclarations = Record<string, string>;
export type StyleDeclarations = Record<string, string>
export interface StyleRule {
selector: string;
source: StyleSource | null;
origin: 'regular' | 'user-agent' | 'injected' | 'inspector';
declarations: StyleDeclarations;
inheritedFrom: string | null;
selector: string
source: StyleSource | null
origin: 'regular' | 'user-agent' | 'injected' | 'inspector'
declarations: StyleDeclarations
inheritedFrom: string | null
}
export interface StylesResult {
element: string;
inlineStyle: StyleDeclarations | null;
rules: StyleRule[];
element: string
inlineStyle: StyleDeclarations | null
rules: StyleRule[]
}
export declare function getStylesForLocator({ locator, cdp: cdpSession, includeUserAgentStyles, }: {
locator: Locator;
cdp: ICDPSession;
includeUserAgentStyles?: boolean;
}): Promise<StylesResult>;
export declare function formatStylesAsText(styles: StylesResult): string;
export declare function getStylesForLocator({
locator,
cdp: cdpSession,
includeUserAgentStyles,
}: {
locator: Locator
cdp: ICDPSession
includeUserAgentStyles?: boolean
}): Promise<StylesResult>
export declare function formatStylesAsText(styles: StylesResult): string
```
## Examples
@@ -112,6 +116,12 @@ async function compareStyles() {
console.log(formatStylesAsText(secondary))
}
export { getElementStyles, inspectButtonStyles, getStylesWithUserAgent, findPropertySource, checkInheritedStyles, compareStyles }
```
export {
getElementStyles,
inspectButtonStyles,
getStylesWithUserAgent,
findPropertySource,
checkInheritedStyles,
compareStyles,
}
```
+5 -6
View File
@@ -1,10 +1,9 @@
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from '@vercel/react-router/vite';
import type { Config } from '@react-router/dev/config'
import { vercelPreset } from '@vercel/react-router/vite'
export default {
appDirectory: "src",
appDirectory: 'src',
ssr: true,
presets: [vercelPreset()],
prerender: ["/"],
} satisfies Config;
prerender: ['/'],
} satisfies Config
+27 -32
View File
@@ -17,59 +17,54 @@
* Usage: tsx website/scripts/generate-placeholders.ts
*/
import sharp from "sharp";
import path from "node:path";
import fs from "node:fs";
import sharp from 'sharp'
import path from 'node:path'
import fs from 'node:fs'
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
const OUTPUT_DIR = path.resolve(import.meta.dirname, "../src/assets/placeholders");
const PLACEHOLDER_PREFIX = "placeholder-";
const PLACEHOLDER_WIDTH = 64;
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const PUBLIC_DIR = path.resolve(import.meta.dirname, '../public')
const OUTPUT_DIR = path.resolve(import.meta.dirname, '../src/assets/placeholders')
const PLACEHOLDER_PREFIX = 'placeholder-'
const PLACEHOLDER_WIDTH = 64
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
const entries = fs.readdirSync(PUBLIC_DIR);
const entries = fs.readdirSync(PUBLIC_DIR)
const images = entries.filter((name) => {
if (name.startsWith(PLACEHOLDER_PREFIX)) {
return false;
return false
}
const ext = path.extname(name).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
});
const ext = path.extname(name).toLowerCase()
return IMAGE_EXTENSIONS.has(ext)
})
if (images.length === 0) {
console.error("No images found in public/");
return;
console.error('No images found in public/')
return
}
for (const name of images) {
const inputPath = path.join(PUBLIC_DIR, name);
const ext = path.extname(name);
const base = path.basename(name, ext);
const outputPath = path.join(OUTPUT_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`);
const inputPath = path.join(PUBLIC_DIR, name)
const ext = path.extname(name)
const base = path.basename(name, ext)
const outputPath = path.join(OUTPUT_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`)
// Skip if placeholder already exists and is newer than source
if (fs.existsSync(outputPath)) {
const srcMtime = fs.statSync(inputPath).mtimeMs;
const outMtime = fs.statSync(outputPath).mtimeMs;
const srcMtime = fs.statSync(inputPath).mtimeMs
const outMtime = fs.statSync(outputPath).mtimeMs
if (outMtime > srcMtime) {
continue;
continue
}
}
await sharp(inputPath)
.resize(PLACEHOLDER_WIDTH)
.png({ compressionLevel: 9 })
.toFile(outputPath);
await sharp(inputPath).resize(PLACEHOLDER_WIDTH).png({ compressionLevel: 9 }).toFile(outputPath)
const stats = fs.statSync(outputPath);
console.error(
`Generated ${PLACEHOLDER_PREFIX}${base}${ext} (${PLACEHOLDER_WIDTH}px wide, ${stats.size} bytes)`,
);
const stats = fs.statSync(outputPath)
console.error(`Generated ${PLACEHOLDER_PREFIX}${base}${ext} (${PLACEHOLDER_WIDTH}px wide, ${stats.size} bytes)`)
}
}
main();
main()
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,7 +1,7 @@
import { startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from 'react-router/dom'
startTransition(() => {
hydrateRoot(document, <HydratedRouter />);
});
hydrateRoot(document, <HydratedRouter />)
})
+56 -74
View File
@@ -1,17 +1,12 @@
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import type {
ActionFunctionArgs,
AppLoadContext,
EntryContext,
LoaderFunctionArgs,
} from "react-router";
import { isRouteErrorResponse, ServerRouter } from "react-router";
import { notifyError } from "./lib/errors";
import { PassThrough } from 'node:stream'
import { createReadableStreamFromReadable } from '@react-router/node'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import type { ActionFunctionArgs, AppLoadContext, EntryContext, LoaderFunctionArgs } from 'react-router'
import { isRouteErrorResponse, ServerRouter } from 'react-router'
import { notifyError } from './lib/errors'
export const streamTimeout = 60 * 20 * 1_000;
export const streamTimeout = 60 * 20 * 1_000
export default function handleRequest(
request: Request,
@@ -21,32 +16,22 @@ export default function handleRequest(
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
loadContext: AppLoadContext,
) {
return isbot(request.headers.get("user-agent") || "")
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
reactRouterContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
reactRouterContext
);
return isbot(request.headers.get('user-agent') || '')
? handleBotRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
: handleBrowserRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext
reactRouterContext: EntryContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let timeoutId: NodeJS.Timeout;
let shellRendered = false
let timeoutId: NodeJS.Timeout
const { pipe, abort } = renderToPipeableStream(
<ServerRouter
context={reactRouterContext}
@@ -55,51 +40,51 @@ function handleBotRequest(
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set('Content-Type', 'text/html')
clearTimeout(timeoutId);
clearTimeout(timeoutId)
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
}),
)
pipe(body);
pipe(body)
},
onShellError(error: unknown) {
clearTimeout(timeoutId);
reject(error);
clearTimeout(timeoutId)
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500;
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
console.error(error)
}
},
}
);
},
)
timeoutId = setTimeout(abort, streamTimeout);
});
timeoutId = setTimeout(abort, streamTimeout)
})
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext
reactRouterContext: EntryContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let timeoutId: NodeJS.Timeout;
let shellRendered = false
let timeoutId: NodeJS.Timeout
const { pipe, abort } = renderToPipeableStream(
<ServerRouter
context={reactRouterContext}
@@ -108,56 +93,53 @@ function handleBrowserRequest(
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set('Content-Type', 'text/html')
clearTimeout(timeoutId);
clearTimeout(timeoutId)
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
}),
)
pipe(body);
pipe(body)
},
onShellError(error: unknown) {
clearTimeout(timeoutId);
reject(error);
clearTimeout(timeoutId)
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500;
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
console.error(error)
}
},
}
);
},
)
timeoutId = setTimeout(abort, streamTimeout);
});
timeoutId = setTimeout(abort, streamTimeout)
})
}
export function handleError(
error: any,
{ request, params, context }: LoaderFunctionArgs | ActionFunctionArgs
) {
export function handleError(error: any, { request, params, context }: LoaderFunctionArgs | ActionFunctionArgs) {
// https://github.com/remix-run/remix/discussions/8933
if (request.signal.aborted || isRouteErrorResponse(error)) {
return;
return
}
if (error?.["status"] === 404) {
return;
if (error?.['status'] === 404) {
return
}
if (error instanceof Error) {
notifyError(error, "unhandled remix error");
notifyError(error, 'unhandled remix error')
} else {
console.error("error", error);
console.error('error', error)
}
}
+14 -14
View File
@@ -1,34 +1,34 @@
import { captureException, flush, init } from "sentries";
import { captureException, flush, init } from 'sentries'
init({
dsn: "https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168",
dsn: 'https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168',
integrations: [],
tracesSampleRate: 0.01,
profilesSampleRate: 0.01,
beforeSend(event) {
if (process.env.NODE_ENV === "development") {
return null;
if (process.env.NODE_ENV === 'development') {
return null
}
if (process.env.BYTECODE_RUN) {
return null;
return null
}
if (event?.["name"] === "AbortError") {
return null;
if (event?.['name'] === 'AbortError') {
return null
}
return event;
return event
},
});
})
export async function notifyError(error: any, msg?: string) {
console.error(msg, error);
captureException(error, { extra: { msg } });
await flush(1000);
console.error(msg, error)
captureException(error, { extra: { msg } })
await flush(1000)
}
export class AppError extends Error {
constructor(message: string) {
super(message);
this.name = "AppError";
super(message)
this.name = 'AppError'
}
}
+5 -5
View File
@@ -1,10 +1,10 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}
+30 -45
View File
@@ -1,40 +1,26 @@
import "website/src/styles/globals.css";
import type { LinksFunction } from "react-router";
import { Route } from "./+types/root";
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import 'website/src/styles/globals.css'
import type { LinksFunction } from 'react-router'
import { Route } from './+types/root'
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
export const links: LinksFunction = () => [
{ rel: "icon", type: "image/png", href: "/favicon-32.png", sizes: "32x32" },
{ rel: "icon", type: "image/png", href: "/favicon-16.png", sizes: "16x16" },
];
{ rel: 'icon', type: 'image/png', href: '/favicon-32.png', sizes: '32x32' },
{ rel: 'icon', type: 'image/png', href: '/favicon-16.png', sizes: '16x16' },
]
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang='en'>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<meta charSet='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='preconnect' href='https://fonts.googleapis.com' />
<link rel='preconnect' href='https://fonts.gstatic.com' crossOrigin='' />
{/* Inter from rsms (same source as next/font) for weight fidelity */}
<link href='https://rsms.me/inter/inter.css' rel='stylesheet' />
<link
href="https://rsms.me/inter/inter.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap"
rel="stylesheet"
href='https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap'
rel='stylesheet'
/>
<Meta />
<Links />
@@ -45,40 +31,39 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Scripts />
</body>
</html>
);
)
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
<h1 className="text-4xl font-bold mb-4">
<div className='flex flex-col items-center justify-center min-h-screen p-8 text-center'>
<h1 className='text-4xl font-bold mb-4'>
{error.status} {error.statusText}
</h1>
<p className="text-lg text-gray-600">{error.data}</p>
<p className='text-lg text-gray-600'>{error.data}</p>
</div>
);
)
} else if (error instanceof Error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-4">Error</h1>
<p className="text-lg text-gray-600 mb-6">{error.message}</p>
<p className="text-sm text-gray-500 mb-2">The stack trace is:</p>
<pre className="bg-gray-100 p-4 rounded-lg text-xs text-left overflow-auto max-w-full border">
<div className='flex flex-col items-center justify-center min-h-screen p-8 text-center max-w-4xl mx-auto'>
<h1 className='text-4xl font-bold mb-4'>Error</h1>
<p className='text-lg text-gray-600 mb-6'>{error.message}</p>
<p className='text-sm text-gray-500 mb-2'>The stack trace is:</p>
<pre className='bg-gray-100 p-4 rounded-lg text-xs text-left overflow-auto max-w-full border'>
{error.stack}
</pre>
</div>
);
)
} else {
return (
<div className="flex items-center justify-center min-h-screen">
<h1 className="text-4xl font-bold text-center">Unknown Error</h1>
<div className='flex items-center justify-center min-h-screen'>
<h1 className='text-4xl font-bold text-center'>Unknown Error</h1>
</div>
);
)
}
}
export default function App() {
return <Outlet />;
return <Outlet />
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
import type { RouteConfig } from '@react-router/dev/routes'
import { flatRoutes } from '@react-router/fs-routes'
export default flatRoutes() satisfies RouteConfig;
export default flatRoutes() satisfies RouteConfig
+176 -249
View File
@@ -4,8 +4,8 @@
* Styles from globals.css (editorial tokens) and editorial-prism.css.
*/
import type { MetaFunction } from "react-router";
import dedent from "string-dedent";
import type { MetaFunction } from 'react-router'
import dedent from 'string-dedent'
import {
EditorialPage,
P,
@@ -19,150 +19,134 @@ import {
OL,
Li,
PixelatedImage,
} from "website/src/components/markdown";
import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png";
} from 'website/src/components/markdown'
import placeholderScreenshot from '../assets/placeholders/placeholder-screenshot@2x.png'
export const meta: MetaFunction = () => {
const title = "Playwriter - Control your Chrome with Playwright API";
const title = 'Playwriter - Control your Chrome with Playwright API'
const description =
"Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat.";
const image = "https://playwriter.dev/og-image.png";
'Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat.'
const image = 'https://playwriter.dev/og-image.png'
return [
{ title },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: image },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:type", content: "website" },
{ property: "og:url", content: "https://playwriter.dev" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
{ name: "twitter:image", content: image },
];
};
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:image', content: image },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: 'https://playwriter.dev' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: title },
{ name: 'twitter:description', content: description },
{ name: 'twitter:image', content: image },
]
}
const tocItems = [
{ label: "Getting started", href: "#getting-started" },
{ label: "How it works", href: "#how-it-works" },
{ label: "Collaboration", href: "#collaboration" },
{ label: "Snapshots", href: "#snapshots" },
{ label: "Visual labels", href: "#visual-labels" },
{ label: "Sessions", href: "#sessions" },
{ label: "Debugger & editor", href: "#debugger-and-editor" },
{ label: "Network interception", href: "#network-interception" },
{ label: "Screen recording", href: "#screen-recording" },
{ label: "Comparison", href: "#comparison" },
{ label: "Remote access", href: "#remote-access" },
{ label: "Security", href: "#security" },
];
{ label: 'Getting started', href: '#getting-started' },
{ label: 'How it works', href: '#how-it-works' },
{ label: 'Collaboration', href: '#collaboration' },
{ label: 'Snapshots', href: '#snapshots' },
{ label: 'Visual labels', href: '#visual-labels' },
{ label: 'Sessions', href: '#sessions' },
{ label: 'Debugger & editor', href: '#debugger-and-editor' },
{ label: 'Network interception', href: '#network-interception' },
{ label: 'Screen recording', href: '#screen-recording' },
{ label: 'Comparison', href: '#comparison' },
{ label: 'Remote access', href: '#remote-access' },
{ label: 'Security', href: '#security' },
]
export default function IndexPage() {
return (
<EditorialPage toc={tocItems} logo="playwriter">
<EditorialPage toc={tocItems} logo='playwriter'>
<P>
You want your agent to control the browser. <strong>Your actual
Chrome</strong> {" \u2014 "} with logins, extensions, and cookies already
there. Not a headless instance that gets blocked by every captcha
and bot detector.{" "}
<A href="https://github.com/remorses/playwriter">Star on GitHub</A>.
You want your agent to control the browser. <strong>Your actual Chrome</strong> {' \u2014 '} with logins,
extensions, and cookies already there. Not a headless instance that gets blocked by every captcha and bot
detector. <A href='https://github.com/remorses/playwriter'>Star on GitHub</A>.
</P>
<div className="bleed" style={{ display: "flex", justifyContent: "center" }}>
<div className='bleed' style={{ display: 'flex', justifyContent: 'center' }}>
<PixelatedImage
src="/screenshot@2x.png"
src='/screenshot@2x.png'
placeholder={placeholderScreenshot}
alt="Playwriter controlling Chrome with accessibility labels overlay"
alt='Playwriter controlling Chrome with accessibility labels overlay'
width={1280}
height={800}
style={{ display: "block", maxWidth: "100%", height: "auto" }}
style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
/>
</div>
<Caption>
Your existing Chrome session. Extensions, logins, cookies {" \u2014 "} all there.
</Caption>
<Caption>Your existing Chrome session. Extensions, logins, cookies {' \u2014 '} all there.</Caption>
<P>
Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents
a fixed set of tools. New Chrome means no logins, no extensions,
instant bot detection, and double the memory. Fixed tools mean the
agent can{"'"}t profile performance, can{"'"}t set breakpoints,
can{"'"}t intercept network requests {" \u2014 "} it can only do what someone
decided to expose.
Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents a fixed set of tools. New Chrome
means no logins, no extensions, instant bot detection, and double the memory. Fixed tools mean the agent can
{"'"}t profile performance, can{"'"}t set breakpoints, can{"'"}t intercept network requests {' \u2014 '} it can
only do what someone decided to expose.
</P>
<P>
Playwriter gives agents the <strong>full Playwright API</strong> through
a single <Code>execute</Code> tool. One tool, any Playwright code,
no wrappers. Low context usage because there{"'"}s no schema bloat
from dozens of tool definitions. And it runs in your existing browser,
so <strong>nothing extra gets spawned</strong>.
Playwriter gives agents the <strong>full Playwright API</strong> through a single <Code>execute</Code> tool. One
tool, any Playwright code, no wrappers. Low context usage because there{"'"}s no schema bloat from dozens of
tool definitions. And it runs in your existing browser, so <strong>nothing extra gets spawned</strong>.
</P>
<Section id="getting-started" title="Getting started">
<Section id='getting-started' title='Getting started'>
<P>
<strong>Four steps</strong> and your agent is browsing.
</P>
<OL>
<Li>
Install the{" "}
<A href="https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe">Chrome extension</A>
Install the{' '}
<A href='https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe'>
Chrome extension
</A>
</Li>
<Li>Click the extension icon on a tab {" \u2014 "} it turns green</Li>
<Li>Click the extension icon on a tab {' \u2014 '} it turns green</Li>
<Li>Install the CLI:</Li>
</OL>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
npm i -g playwriter
`}</CodeBlock>
<P>
Then install the <strong>skill</strong> {" \u2014 "} it teaches your agent how to use
Playwriter: which selectors to use, how to avoid timeouts, how to
read snapshots, and all available utilities.
Then install the <strong>skill</strong> {' \u2014 '} it teaches your agent how to use Playwriter: which
selectors to use, how to avoid timeouts, how to read snapshots, and all available utilities.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
npx -y skills add remorses/playwriter
`}</CodeBlock>
<P>
The extension connects your browser to a <strong>local WebSocket relay</strong> on{" "}
<Code>localhost:19988</Code>. The CLI sends Playwright
code through the relay. No remote servers, no accounts, nothing
leaves your machine.
The extension connects your browser to a <strong>local WebSocket relay</strong> on{' '}
<Code>localhost:19988</Code>. The CLI sends Playwright code through the relay. No remote servers, no accounts,
nothing leaves your machine.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter session new # new sandbox, outputs id (e.g. 1)
playwriter -s 1 -e "await page.goto('https://example.com')"
playwriter -s 1 -e "console.log(await snapshot({ page }))"
playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"
`}</CodeBlock>
<Caption>
Extension icon green = connected. Gray = not attached to this tab.
</Caption>
<Caption>Extension icon green = connected. Gray = not attached to this tab.</Caption>
</Section>
<Section id="how-it-works" title="How it works">
<Section id='how-it-works' title='How it works'>
<P>
Click the extension icon on a tab {" \u2014 "} it attaches via{" "}
<Code>chrome.debugger</Code> and opens a WebSocket to a
local relay. Your agent (CLI, MCP, or a Playwright script) connects
to the same relay. <strong>CDP commands flow through</strong>; the extension
forwards them to Chrome and sends responses back. No Chrome restart,
no flags, no special setup.
Click the extension icon on a tab {' \u2014 '} it attaches via <Code>chrome.debugger</Code> and opens a
WebSocket to a local relay. Your agent (CLI, MCP, or a Playwright script) connects to the same relay.{' '}
<strong>CDP commands flow through</strong>; the extension forwards them to Chrome and sends responses back. No
Chrome restart, no flags, no special setup.
</P>
<CodeBlock lang="bash" lineHeight="1.3">{dedent`
<CodeBlock lang='bash' lineHeight='1.3'>{dedent`
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ BROWSER │ │ LOCALHOST │ │ CLIENT │
│ │ │ │ │ │
@@ -180,42 +164,35 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
The relay <strong>multiplexes sessions</strong>, so multiple agents
or CLI instances can work with the same browser at the same time.
The relay <strong>multiplexes sessions</strong>, so multiple agents or CLI instances can work with the same
browser at the same time.
</P>
</Section>
<Section id="collaboration" title="Collaboration">
<Section id='collaboration' title='Collaboration'>
<P>
Because the agent works in <strong>your browser</strong>, you can
collaborate. You see everything it does in real time. When it hits
a captcha, <strong>you solve it</strong>. When a consent wall
appears, you click through it. When the agent gets stuck, you
disable the extension on that tab, fix things manually, re-enable
Because the agent works in <strong>your browser</strong>, you can collaborate. You see everything it does in
real time. When it hits a captcha, <strong>you solve it</strong>. When a consent wall appears, you click
through it. When the agent gets stuck, you disable the extension on that tab, fix things manually, re-enable
it, and the agent picks up where it left off.
</P>
<P>
You{"'"}re not watching a remote screen or reading logs after the
fact. You{"'"}re <strong>sharing a browser</strong> {" \u2014 "} the
agent does the repetitive work, you step in when it needs a human.
You{"'"}re not watching a remote screen or reading logs after the fact. You{"'"}re{' '}
<strong>sharing a browser</strong> {' \u2014 '} the agent does the repetitive work, you step in when it needs
a human.
</P>
</Section>
<Section id="snapshots" title="Accessibility snapshots">
<Section id='snapshots' title='Accessibility snapshots'>
<P>
Your agent needs to <strong>see the page</strong> before it can act.
Accessibility snapshots return every interactive element as text,
with Playwright locators attached. <strong>5{"\u2013"}20KB instead of
100KB+</strong> for a screenshot {" \u2014 "} cheaper, faster, and the
Your agent needs to <strong>see the page</strong> before it can act. Accessibility snapshots return every
interactive element as text, with Playwright locators attached.{' '}
<strong>5{'\u2013'}20KB instead of 100KB+</strong> for a screenshot {' \u2014 '} cheaper, faster, and the
agent can parse them without vision.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "await snapshot({ page })"
# Output:
@@ -227,13 +204,12 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Each line ends with a <strong>locator</strong> you can pass directly to{" "}
<Code>page.locator()</Code>. Subsequent calls return a
<strong> diff</strong>, so you only see what changed. Use{" "}
<Code>search</Code> to filter large pages.
Each line ends with a <strong>locator</strong> you can pass directly to <Code>page.locator()</Code>.
Subsequent calls return a<strong> diff</strong>, so you only see what changed. Use <Code>search</Code> to
filter large pages.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Search for specific elements
playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
@@ -242,25 +218,19 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Use snapshots as the <strong>primary way to read pages</strong>. Only
reach for screenshots when spatial layout matters {" \u2014 "} grids,
dashboards, maps.
Use snapshots as the <strong>primary way to read pages</strong>. Only reach for screenshots when spatial
layout matters {' \u2014 '} grids, dashboards, maps.
</P>
</Section>
<Section id="visual-labels" title="Visual labels">
<Section id='visual-labels' title='Visual labels'>
<P>
When the agent needs to understand <strong>where things are on
screen</strong>,{" "}
<Code>screenshotWithAccessibilityLabels</Code> overlays{" "}
<strong>Vimium-style labels</strong> on every interactive element.
The agent sees the screenshot, reads the labels, and clicks by
reference.
When the agent needs to understand <strong>where things are on screen</strong>,{' '}
<Code>screenshotWithAccessibilityLabels</Code> overlays <strong>Vimium-style labels</strong> on every
interactive element. The agent sees the screenshot, reads the labels, and clicks by reference.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })"
# Returns screenshot + accessibility snapshot with aria-ref selectors
@@ -268,29 +238,22 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Labels are <strong>color-coded by element type</strong>: yellow for links, orange for
buttons, coral for inputs, pink for checkboxes, peach for sliders,
salmon for menus, amber for tabs. The ref system is shared with{" "}
<Code>snapshot()</Code>, so you can switch between text
and visual modes freely.
Labels are <strong>color-coded by element type</strong>: yellow for links, orange for buttons, coral for
inputs, pink for checkboxes, peach for sliders, salmon for menus, amber for tabs. The ref system is shared
with <Code>snapshot()</Code>, so you can switch between text and visual modes freely.
</P>
<Caption>
Vimium-style labels. Screenshot + snapshot in one call.
</Caption>
<Caption>Vimium-style labels. Screenshot + snapshot in one call.</Caption>
</Section>
<Section id="sessions" title="Sessions">
<Section id='sessions' title='Sessions'>
<P>
Run <strong>multiple agents at once</strong> without them stepping on
each other. Each session is an isolated sandbox with its own{" "}
<Code>state</Code> object. Variables, pages, and listeners
persist between calls. Browser tabs are shared, but state is not.
Run <strong>multiple agents at once</strong> without them stepping on each other. Each session is an isolated
sandbox with its own <Code>state</Code> object. Variables, pages, and listeners persist between calls. Browser
tabs are shared, but state is not.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter session new # => 1
playwriter session new # => 2
playwriter session list # shows sessions + state keys
@@ -303,30 +266,26 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Create your own page to <strong>avoid interference</strong> from other agents. Reuse
an existing <Code>about:blank</Code> tab or create a
fresh one, and store it in <Code>state</Code>.
Create your own page to <strong>avoid interference</strong> from other agents. Reuse an existing{' '}
<Code>about:blank</Code> tab or create a fresh one, and store it in <Code>state</Code>.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')"
# All subsequent calls use state.myPage
playwriter -s 1 -e "console.log(await state.myPage.title())"
`}</CodeBlock>
</Section>
<Section id="debugger-and-editor" title="Debugger & editor">
<Section id='debugger-and-editor' title='Debugger & editor'>
<P>
Things no other browser MCP can do. <strong>Set breakpoints</strong>,
step through code, inspect variables at runtime. <strong>Live-edit
page scripts and CSS</strong> without reloading. Full Chrome DevTools
Protocol access, not a watered-down subset.
Things no other browser MCP can do. <strong>Set breakpoints</strong>, step through code, inspect variables at
runtime. <strong>Live-edit page scripts and CSS</strong> without reloading. Full Chrome DevTools Protocol
access, not a watered-down subset.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Set breakpoints and debug
playwriter -s 1 -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()"
playwriter -s 1 -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))"
@@ -338,28 +297,21 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for
toggling debug flags, patching broken code, or testing quick fixes
without touching source files. The editor also supports{" "}
Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for toggling debug flags,
patching broken code, or testing quick fixes without touching source files. The editor also supports{' '}
<Code>grep</Code> across all loaded scripts.
</P>
<Caption>
Breakpoints, stepping, variable inspection {" \u2014 "} from the CLI.
</Caption>
<Caption>Breakpoints, stepping, variable inspection {' \u2014 '} from the CLI.</Caption>
</Section>
<Section id="network-interception" title="Network interception">
<Section id='network-interception' title='Network interception'>
<P>
Let the agent <strong>watch network traffic</strong> to
reverse-engineer APIs, scrape data behind JavaScript rendering,
or debug failing requests. Captured data lives in{" "}
<Code>state</Code> and persists across calls.
Let the agent <strong>watch network traffic</strong> to reverse-engineer APIs, scrape data behind JavaScript
rendering, or debug failing requests. Captured data lives in <Code>state</Code> and persists across calls.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Start intercepting
playwriter -s 1 -e "state.responses = []; page.on('response', async res => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }); } catch {} } })"
@@ -372,24 +324,20 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
<strong>Faster than scraping the DOM.</strong> The agent captures the
real API calls, inspects their schemas, and replays them with
different parameters. Works for pagination, authenticated endpoints,
and anything behind client-side rendering.
<strong>Faster than scraping the DOM.</strong> The agent captures the real API calls, inspects their schemas,
and replays them with different parameters. Works for pagination, authenticated endpoints, and anything behind
client-side rendering.
</P>
</Section>
<Section id="screen-recording" title="Screen recording">
<Section id='screen-recording' title='Screen recording'>
<P>
Have the agent <strong>record what it{"'"}s doing</strong> as MP4
video. The recording uses <Code>chrome.tabCapture</Code> and
runs in the extension context, so it <strong>survives page
navigation</strong>.
Have the agent <strong>record what it{"'"}s doing</strong> as MP4 video. The recording uses{' '}
<Code>chrome.tabCapture</Code> and runs in the extension context, so it{' '}
<strong>survives page navigation</strong>.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Start recording
playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })"
@@ -403,75 +351,64 @@ export default function IndexPage() {
<P>
Unlike <Code>getDisplayMedia</Code>, this approach
<strong> persists across navigations</strong> because the extension holds the{" "}
<Code>MediaRecorder</Code>, not the page. You can also
check recording status with <Code>isRecording</Code> or
cancel without saving with <Code>cancelRecording</Code>.
<strong> persists across navigations</strong> because the extension holds the <Code>MediaRecorder</Code>, not
the page. You can also check recording status with <Code>isRecording</Code> or cancel without saving with{' '}
<Code>cancelRecording</Code>.
</P>
<Caption>
Native tab capture. 30{"\u2013"}60fps. Survives navigation.
</Caption>
<Caption>Native tab capture. 30{'\u2013'}60fps. Survives navigation.</Caption>
</Section>
<Section id="comparison" title="Comparison">
<P>
Why use this over the alternatives.
</P>
<Section id='comparison' title='Comparison'>
<P>Why use this over the alternatives.</P>
<ComparisonTable
title="vs Playwright MCP"
headers={["", "Playwright MCP", "Playwriter"]}
title='vs Playwright MCP'
headers={['', 'Playwright MCP', 'Playwriter']}
rows={[
["Browser", "Spawns new Chrome", "Uses your Chrome"],
["Extensions", "None", "Your existing ones"],
["Login state", "Fresh", "Already logged in"],
["Bot detection", "Always detected", "Can bypass"],
["Collaboration", "Separate window", "Same browser as user"],
['Browser', 'Spawns new Chrome', 'Uses your Chrome'],
['Extensions', 'None', 'Your existing ones'],
['Login state', 'Fresh', 'Already logged in'],
['Bot detection', 'Always detected', 'Can bypass'],
['Collaboration', 'Separate window', 'Same browser as user'],
]}
/>
<ComparisonTable
title="vs BrowserMCP"
headers={["", "BrowserMCP", "Playwriter"]}
title='vs BrowserMCP'
headers={['', 'BrowserMCP', 'Playwriter']}
rows={[
["Tools", "12+ dedicated tools", "1 execute tool"],
["API", "Limited actions", "Full Playwright"],
["Context usage", "High (tool schemas)", "Low"],
["LLM knowledge", "Must learn tools", "Already knows Playwright"],
['Tools', '12+ dedicated tools', '1 execute tool'],
['API', 'Limited actions', 'Full Playwright'],
['Context usage', 'High (tool schemas)', 'Low'],
['LLM knowledge', 'Must learn tools', 'Already knows Playwright'],
]}
/>
<ComparisonTable
title="vs Claude Browser Extension"
headers={["", "Claude Extension", "Playwriter"]}
title='vs Claude Browser Extension'
headers={['', 'Claude Extension', 'Playwriter']}
rows={[
["Agent support", "Claude only", "Any MCP client"],
["Windows WSL", "No", "Yes"],
["Context method", "Screenshots (100KB+)", "A11y snapshots (5\u201320KB)"],
["Playwright API", "No", "Full"],
["Debugger", "No", "Yes"],
["Live code editing", "No", "Yes"],
["Network interception", "Limited", "Full"],
["Raw CDP access", "No", "Yes"],
['Agent support', 'Claude only', 'Any MCP client'],
['Windows WSL', 'No', 'Yes'],
['Context method', 'Screenshots (100KB+)', 'A11y snapshots (5\u201320KB)'],
['Playwright API', 'No', 'Full'],
['Debugger', 'No', 'Yes'],
['Live code editing', 'No', 'Yes'],
['Network interception', 'Limited', 'Full'],
['Raw CDP access', 'No', 'Yes'],
]}
/>
</Section>
<Section id="remote-access" title="Remote access">
<Section id='remote-access' title='Remote access'>
<P>
Control Chrome on a <strong>remote machine</strong> {" \u2014 "} a headless
Mac mini, a cloud VM, a devcontainer. A{" "}
<A href="https://traforo.dev">traforo</A>{" "}
tunnel exposes the relay through Cloudflare. <strong>No VPN, no
firewall rules, no port forwarding.</strong>
Control Chrome on a <strong>remote machine</strong> {' \u2014 '} a headless Mac mini, a cloud VM, a
devcontainer. A <A href='https://traforo.dev'>traforo</A> tunnel exposes the relay through Cloudflare.{' '}
<strong>No VPN, no firewall rules, no port forwarding.</strong>
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# On the host machine — start relay with tunnel
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
@@ -482,46 +419,36 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Also works on a <strong>LAN without tunnels</strong> {" \u2014 "} just set{" "}
<Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP
too {" \u2014 "} set <Code>PLAYWRITER_HOST</Code> and{" "}
<Code>PLAYWRITER_TOKEN</Code> in your MCP client env config.
Use cases: headless Mac mini, remote user support,
multi-machine automation, dev from a VM or devcontainer.
Also works on a <strong>LAN without tunnels</strong> {' \u2014 '} just set{' '}
<Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP too {' \u2014 '} set <Code>PLAYWRITER_HOST</Code> and{' '}
<Code>PLAYWRITER_TOKEN</Code> in your MCP client env config. Use cases: headless Mac mini, remote user
support, multi-machine automation, dev from a VM or devcontainer.
</P>
</Section>
<Section id="security" title="Security">
<Section id='security' title='Security'>
<P>
Everything runs <strong>on your machine</strong>. The relay binds
to <Code>localhost:19988</Code> and only accepts connections
from the extension. No remote server, no account, no telemetry.
Everything runs <strong>on your machine</strong>. The relay binds to <Code>localhost:19988</Code> and only
accepts connections from the extension. No remote server, no account, no telemetry.
</P>
<List>
<Li>
<strong>Local only</strong> {" \u2014 "} WebSocket server binds to
localhost. Nothing leaves your machine.
<strong>Local only</strong> {' \u2014 '} WebSocket server binds to localhost. Nothing leaves your machine.
</Li>
<Li>
<strong>Origin validation</strong> {" \u2014 "} only the Playwriter
extension origin is accepted. Browsers cannot spoof the Origin
header, so malicious websites cannot connect.
<strong>Origin validation</strong> {' \u2014 '} only the Playwriter extension origin is accepted. Browsers
cannot spoof the Origin header, so malicious websites cannot connect.
</Li>
<Li>
<strong>Explicit consent</strong> {" \u2014 "} only tabs where you
clicked the extension icon are controlled. No background access.
<strong>Explicit consent</strong> {' \u2014 '} only tabs where you clicked the extension icon are
controlled. No background access.
</Li>
<Li>
<strong>Visible automation</strong> {" \u2014 "} Chrome shows an
automation banner on controlled tabs.
<strong>Visible automation</strong> {' \u2014 '} Chrome shows an automation banner on controlled tabs.
</Li>
</List>
</Section>
</EditorialPage>
);
)
}
+7 -7
View File
@@ -1,24 +1,24 @@
import React from "react";
import { sleep } from "../lib/utils";
import { Route } from "./+types/defer-example";
import React from 'react'
import { sleep } from '../lib/utils'
import { Route } from './+types/defer-example'
async function getProjectLocation() {
return Promise.resolve().then(() => sleep(1000).then(() => "hi"));
return Promise.resolve().then(() => sleep(1000).then(() => 'hi'))
}
export async function loader({}: Route.LoaderArgs) {
return {
project: getProjectLocation(),
};
}
}
export default function ProjectRoute({ loaderData }: Route.ComponentProps) {
const location = React.use(loaderData.project);
const location = React.use(loaderData.project)
return (
<main>
<h1>Let's locate your project</h1>
<p>Your project is at {location}.</p>
</main>
);
)
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { redirect } from 'react-router';
import { redirect } from 'react-router'
export const loader = () => {
throw redirect('https://github.com/remorses/playwriter');
};
throw redirect('https://github.com/remorses/playwriter')
}
export default function Index() {
return null;
return null
}
+5 -6
View File
@@ -12,13 +12,12 @@
*/
/* Base code style - override Prism defaults */
code[class*="language-"],
pre[class*="language-"] {
code[class*='language-'],
pre[class*='language-'] {
color: #1e293b;
background: none;
text-shadow: none;
font-family: var(--font-code, "SF Mono", "SFMono-Regular", "Consolas",
"Liberation Mono", Menlo, Courier, monospace);
font-family: var(--font-code, 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo, Courier, monospace);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -163,8 +162,8 @@ pre[class*="language-"] {
}
}
code[class*="language-"],
pre[class*="language-"] {
code[class*='language-'],
pre[class*='language-'] {
@variant dark {
color: var(--prism-fg);
}
+8 -5
View File
@@ -25,11 +25,12 @@
======================================================================== */
@font-face {
font-family: "JetBrainsMono NF Mono";
font-family: 'JetBrainsMono NF Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("https://raw.githubusercontent.com/ryanoasis/nerd-fonts/refs/tags/v3.3.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf") format("truetype");
src: url('https://raw.githubusercontent.com/ryanoasis/nerd-fonts/refs/tags/v3.3.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf')
format('truetype');
}
/* ========================================================================
@@ -38,7 +39,9 @@
.editorial-page {
font-optical-sizing: auto;
font-feature-settings: "liga" 1, "calt" 1;
font-feature-settings:
'liga' 1,
'calt' 1;
}
.editorial-page ::selection {
@@ -119,7 +122,7 @@
}
.inline-code::before {
content: "";
content: '';
position: absolute;
inset: -1.26px 0;
background: rgba(0, 0, 0, 0.04);
@@ -144,7 +147,7 @@
======================================================================== */
.editorial-page::before {
content: "";
content: '';
pointer-events: none;
z-index: 9;
position: fixed;
+238 -243
View File
@@ -1,294 +1,289 @@
@import "tailwindcss";
@import "./editorial.css";
@import "./editorial-prism.css";
@import 'tailwindcss';
@import './editorial.css';
@import './editorial-prism.css';
@custom-variant dark (@media (prefers-color-scheme: dark));
@plugin "@tailwindcss/typography";
:root {
/* ===== shadcn/ui tokens ===== */
--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.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 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);
/* ===== shadcn/ui tokens ===== */
--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.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 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.637 0.237 25.331);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.871 0.006 286.286);
--ring: oklch(0.871 0.006 286.286);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.871 0.006 286.286);
--sidebar-primary: oklch(0.37 0.013 285.805);
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.871 0.006 286.286);
/* ===== Editorial design tokens ===== */
--font-primary:
'Inter var', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
--font-secondary: 'Newsreader', Georgia, 'Times New Roman', serif;
--font-code:
'JetBrainsMono NF Mono', 'JetBrains Mono', 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo,
Courier, monospace;
/* Bleed: code blocks & images extend beyond prose column.
44px = 8px div padding-left + 36px line-number width */
--bleed: 44px;
/* Spacing scale */
--spacing-xxs: 0.5rem;
--spacing-xs: 1rem;
--spacing-sm: 1.5rem;
--spacing-md: 2rem;
--spacing-lg: 2.5rem;
--spacing-xl: 3rem;
--spacing-xxl: 3.5rem;
/* Timing */
--duration-snappy: 220ms;
--ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1);
--duration-swift: 800ms;
--ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--duration-smooth: 300ms;
--ease-smooth: cubic-bezier(0.19, 1, 0.22, 1);
/* Colors — near-black, not pure black */
--text-primary: rgb(17, 17, 17);
--text-secondary: rgba(0, 0, 0, 0.4);
--text-tertiary: rgba(0, 0, 0, 0.25);
--text-muted: rgba(0, 0, 0, 0.5);
--text-hover: rgba(0, 0, 0, 0.7);
--bg: #fff;
--page-border: #e3e3e3;
--divider: rgb(242, 242, 242);
--code-bg: rgba(0, 0, 0, 0.02);
--code-line-nr: rgba(0, 0, 0, 0.3);
--selection-bg: rgba(0, 0, 0, 0.08);
/* Button / back button */
--btn-bg: #fff;
--btn-shadow:
rgba(0, 0, 0, 0.08) 0px 2px 8px, rgba(0, 0, 0, 0.04) 0px 4px 16px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
/* Link accent color (renamed from --accent to avoid shadcn conflict) */
--link-accent: #0969da;
/* P3 accent colors (renamed from --primary/--secondary to avoid shadcn conflict) */
--brand-primary: #3e9fff;
--brand-secondary: #f09637;
/* Overlay / glass */
--overlay-filter: blur(1rem);
--overlay-bg: hsla(0, 0%, 100%, 0.8);
--overlay-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04), 0 1.625rem 3.375rem rgba(0, 0, 0, 0.04), 0 1rem 2rem rgba(0, 0, 0, 0.03),
0 0.625rem 1rem rgba(0, 0, 0, 0.024), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016), 0 0 0.125rem rgba(0, 0, 0, 0.01);
/* Header fade gradient stops (white) */
--fade-0: rgb(255, 255, 255);
--fade-1: rgba(255, 255, 255, 0.737);
--fade-2: rgba(255, 255, 255, 0.541);
--fade-3: rgba(255, 255, 255, 0.382);
--fade-4: rgba(255, 255, 255, 0.278);
--fade-5: rgba(255, 255, 255, 0.194);
--fade-6: rgba(255, 255, 255, 0.126);
--fade-7: rgba(255, 255, 255, 0.075);
--fade-8: rgba(255, 255, 255, 0.042);
--fade-9: rgba(255, 255, 255, 0.021);
--fade-10: rgba(255, 255, 255, 0.008);
--fade-11: rgba(255, 255, 255, 0.002);
--fade-12: rgba(255, 255, 255, 0);
/* Dark mode overrides — uses prefers-color-scheme media query
via @custom-variant dark above */
@variant dark {
/* shadcn/ui dark */
--background: oklch(0.21 0.006 285.885);
--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.985 0 0);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 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.637 0.237 25.331);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.871 0.006 286.286);
--ring: oklch(0.871 0.006 286.286);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.21 0.006 285.885);
--border: oklch(0.274 0.006 286.033);
--input: oklch(0.274 0.006 286.033);
--ring: oklch(0.442 0.017 285.786);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #000;
--sidebar-foreground: oklch(0.871 0.006 286.286);
--sidebar-primary: oklch(0.37 0.013 285.805);
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.871 0.006 286.286);
--sidebar-border: oklch(0.274 0.006 286.033);
--sidebar-ring: oklch(0.442 0.017 285.786);
/* ===== Editorial design tokens ===== */
--font-primary: "Inter var", "Inter", system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-secondary: "Newsreader", Georgia, "Times New Roman", serif;
--font-code: "JetBrainsMono NF Mono", "JetBrains Mono", "SF Mono",
"SFMono-Regular", "Consolas", "Liberation Mono", Menlo, Courier, monospace;
/* Editorial dark */
--text-primary: rgba(255, 255, 255, 0.9);
--text-secondary: rgba(255, 255, 255, 0.45);
--text-tertiary: rgba(255, 255, 255, 0.25);
--text-muted: rgba(255, 255, 255, 0.35);
--text-hover: rgba(255, 255, 255, 0.7);
--bg: rgb(17, 17, 17);
--page-border: rgba(255, 255, 255, 0.1);
--divider: rgba(255, 255, 255, 0.06);
--code-bg: rgba(255, 255, 255, 0.05);
--code-line-nr: rgba(255, 255, 255, 0.3);
--selection-bg: rgba(255, 255, 255, 0.1);
/* Bleed: code blocks & images extend beyond prose column.
44px = 8px div padding-left + 36px line-number width */
--bleed: 44px;
--btn-bg: rgb(30, 30, 30);
--btn-shadow:
rgba(255, 255, 255, 0.05) 0px 2px 8px, rgba(255, 255, 255, 0.02) 0px 4px 16px,
rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
/* Spacing scale */
--spacing-xxs: 0.5rem;
--spacing-xs: 1rem;
--spacing-sm: 1.5rem;
--spacing-md: 2rem;
--spacing-lg: 2.5rem;
--spacing-xl: 3rem;
--spacing-xxl: 3.5rem;
--link-accent: #58a6ff;
--brand-primary: #60a5fa;
--brand-secondary: #f5a623;
/* Timing */
--duration-snappy: 220ms;
--ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1);
--duration-swift: 800ms;
--ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--duration-smooth: 300ms;
--ease-smooth: cubic-bezier(0.19, 1, 0.22, 1);
--overlay-bg: hsla(0, 0%, 7%, 0.8);
--overlay-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06), 0 1.625rem 3.375rem rgba(0, 0, 0, 0.3), 0 1rem 2rem rgba(0, 0, 0, 0.2),
0 0.625rem 1rem rgba(0, 0, 0, 0.15), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.08), 0 0 0.125rem rgba(0, 0, 0, 0.05);
/* Colors — near-black, not pure black */
--text-primary: rgb(17, 17, 17);
--text-secondary: rgba(0, 0, 0, 0.4);
--text-tertiary: rgba(0, 0, 0, 0.25);
--text-muted: rgba(0, 0, 0, 0.5);
--text-hover: rgba(0, 0, 0, 0.7);
--bg: #fff;
--page-border: #e3e3e3;
--divider: rgb(242, 242, 242);
--code-bg: rgba(0, 0, 0, 0.02);
--code-line-nr: rgba(0, 0, 0, 0.3);
--selection-bg: rgba(0, 0, 0, 0.08);
/* Button / back button */
--btn-bg: #fff;
--btn-shadow: rgba(0, 0, 0, 0.08) 0px 2px 8px,
rgba(0, 0, 0, 0.04) 0px 4px 16px,
rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
/* Link accent color (renamed from --accent to avoid shadcn conflict) */
--link-accent: #0969da;
/* P3 accent colors (renamed from --primary/--secondary to avoid shadcn conflict) */
--brand-primary: #3e9fff;
--brand-secondary: #f09637;
/* Overlay / glass */
--overlay-filter: blur(1rem);
--overlay-bg: hsla(0, 0%, 100%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04),
0 1.625rem 3.375rem rgba(0, 0, 0, 0.04),
0 1rem 2rem rgba(0, 0, 0, 0.03),
0 0.625rem 1rem rgba(0, 0, 0, 0.024),
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016),
0 0 0.125rem rgba(0, 0, 0, 0.01);
/* Header fade gradient stops (white) */
--fade-0: rgb(255, 255, 255);
--fade-1: rgba(255, 255, 255, 0.737);
--fade-2: rgba(255, 255, 255, 0.541);
--fade-3: rgba(255, 255, 255, 0.382);
--fade-4: rgba(255, 255, 255, 0.278);
--fade-5: rgba(255, 255, 255, 0.194);
--fade-6: rgba(255, 255, 255, 0.126);
--fade-7: rgba(255, 255, 255, 0.075);
--fade-8: rgba(255, 255, 255, 0.042);
--fade-9: rgba(255, 255, 255, 0.021);
--fade-10: rgba(255, 255, 255, 0.008);
--fade-11: rgba(255, 255, 255, 0.002);
--fade-12: rgba(255, 255, 255, 0);
/* Dark mode overrides — uses prefers-color-scheme media query
via @custom-variant dark above */
@variant dark {
/* shadcn/ui dark */
--background: oklch(0.21 0.006 285.885);
--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.985 0 0);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 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.637 0.237 25.331);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.274 0.006 286.033);
--input: oklch(0.274 0.006 286.033);
--ring: oklch(0.442 0.017 285.786);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #000;
--sidebar-foreground: oklch(0.871 0.006 286.286);
--sidebar-primary: oklch(0.37 0.013 285.805);
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
--sidebar-border: oklch(0.274 0.006 286.033);
--sidebar-ring: oklch(0.442 0.017 285.786);
/* Editorial dark */
--text-primary: rgba(255, 255, 255, 0.9);
--text-secondary: rgba(255, 255, 255, 0.45);
--text-tertiary: rgba(255, 255, 255, 0.25);
--text-muted: rgba(255, 255, 255, 0.35);
--text-hover: rgba(255, 255, 255, 0.7);
--bg: rgb(17, 17, 17);
--page-border: rgba(255, 255, 255, 0.1);
--divider: rgba(255, 255, 255, 0.06);
--code-bg: rgba(255, 255, 255, 0.05);
--code-line-nr: rgba(255, 255, 255, 0.3);
--selection-bg: rgba(255, 255, 255, 0.1);
--btn-bg: rgb(30, 30, 30);
--btn-shadow: rgba(255, 255, 255, 0.05) 0px 2px 8px,
rgba(255, 255, 255, 0.02) 0px 4px 16px,
rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
--link-accent: #58a6ff;
--brand-primary: #60a5fa;
--brand-secondary: #f5a623;
--overlay-bg: hsla(0, 0%, 7%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06),
0 1.625rem 3.375rem rgba(0, 0, 0, 0.3),
0 1rem 2rem rgba(0, 0, 0, 0.2),
0 0.625rem 1rem rgba(0, 0, 0, 0.15),
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.08),
0 0 0.125rem rgba(0, 0, 0, 0.05);
/* Header fade gradient stops (dark) */
--fade-0: rgb(17, 17, 17);
--fade-1: rgba(17, 17, 17, 0.737);
--fade-2: rgba(17, 17, 17, 0.541);
--fade-3: rgba(17, 17, 17, 0.382);
--fade-4: rgba(17, 17, 17, 0.278);
--fade-5: rgba(17, 17, 17, 0.194);
--fade-6: rgba(17, 17, 17, 0.126);
--fade-7: rgba(17, 17, 17, 0.075);
--fade-8: rgba(17, 17, 17, 0.042);
--fade-9: rgba(17, 17, 17, 0.021);
--fade-10: rgba(17, 17, 17, 0.008);
--fade-11: rgba(17, 17, 17, 0.002);
--fade-12: rgba(17, 17, 17, 0);
}
/* Header fade gradient stops (dark) */
--fade-0: rgb(17, 17, 17);
--fade-1: rgba(17, 17, 17, 0.737);
--fade-2: rgba(17, 17, 17, 0.541);
--fade-3: rgba(17, 17, 17, 0.382);
--fade-4: rgba(17, 17, 17, 0.278);
--fade-5: rgba(17, 17, 17, 0.194);
--fade-6: rgba(17, 17, 17, 0.126);
--fade-7: rgba(17, 17, 17, 0.075);
--fade-8: rgba(17, 17, 17, 0.042);
--fade-9: rgba(17, 17, 17, 0.021);
--fade-10: rgba(17, 17, 17, 0.008);
--fade-11: rgba(17, 17, 17, 0.002);
--fade-12: rgba(17, 17, 17, 0);
}
}
/* P3 wide-gamut accent colors */
@supports (color: color(display-p3 1 1 1)) {
:root {
--brand-primary: color(display-p3 0.243137 0.623529 1 / 1);
--brand-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1);
}
:root {
--brand-primary: color(display-p3 0.243137 0.623529 1 / 1);
--brand-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1);
}
}
@theme inline {
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--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);
--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-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom scrollbar styles: shadcn-inspired */
::-webkit-scrollbar {
width: 6px;
height: 6px;
background: transparent;
border-radius: var(--radius-md);
width: 6px;
height: 6px;
background: transparent;
border-radius: var(--radius-md);
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: var(--radius-md);
border: none;
background: rgba(0, 0, 0, 0.15);
border-radius: var(--radius-md);
border: none;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
background: rgba(0, 0, 0, 0.3);
}
@variant dark {
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
}
+15 -15
View File
@@ -1,26 +1,26 @@
/// <reference types="vitest/config" />
import { reactRouter } from "@react-router/dev/vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import EnvironmentPlugin from "vite-plugin-environment";
import { defineConfig } from "vite";
import { reactRouter } from '@react-router/dev/vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import EnvironmentPlugin from 'vite-plugin-environment'
import { defineConfig } from 'vite'
import {
viteExternalsPlugin,
enablePreserveModulesPlugin,
} from "@xmorse/deployment-utils/dist/vite-externals-plugin.js";
import { reactRouterServerPlugin } from "@xmorse/deployment-utils/dist/react-router.js";
import tsconfigPaths from "vite-tsconfig-paths";
} from '@xmorse/deployment-utils/dist/vite-externals-plugin.js'
import { reactRouterServerPlugin } from '@xmorse/deployment-utils/dist/react-router.js'
import tsconfigPaths from 'vite-tsconfig-paths'
const NODE_ENV = JSON.stringify(process.env.NODE_ENV || "production");
const NODE_ENV = JSON.stringify(process.env.NODE_ENV || 'production')
export default defineConfig({
clearScreen: false,
define: {
"process.env.NODE_ENV": NODE_ENV,
'process.env.NODE_ENV': NODE_ENV,
},
test: {
pool: "threads",
exclude: ["**/dist/**", "**/esm/**", "**/node_modules/**", "**/e2e/**"],
pool: 'threads',
exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'],
poolOptions: {
threads: {
isolate: false,
@@ -28,8 +28,8 @@ export default defineConfig({
},
},
plugins: [
EnvironmentPlugin("all", { prefix: "PUBLIC" }),
EnvironmentPlugin("all", { prefix: "NEXT_PUBLIC" }),
EnvironmentPlugin('all', { prefix: 'PUBLIC' }),
EnvironmentPlugin('all', { prefix: 'NEXT_PUBLIC' }),
process.env.VITEST ? react() : reactRouter(),
tsconfigPaths(),
// viteExternalsPlugin({
@@ -44,4 +44,4 @@ export default defineConfig({
// transformMixedEsModules: true,
// },
// },
});
})