@@ -278,6 +301,25 @@ export function Section({ id, title, children }: { id: string; title: string; ch
);
}
+export function OL({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
export function List({ children }: { children: React.ReactNode }) {
return (
-
Playwriter lets you control your Chrome browser with the full
Playwright API. A Chrome extension, a local relay, and a CLI. No new
- browser windows, no Chrome flags, no context bloat.
+ browser windows, no Chrome flags, no context bloat.{" "}
+ Star on GitHub.
@@ -59,375 +63,402 @@ export default function LivelinePage() {
-
- Three steps. Extension, icon click, then you're automating.
-
+
+ Three steps. Extension, icon click, then you're automating.
+
- {`# 1. Install the Chrome extension
-# https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe
+
+ -
+ Install the{" "}
+ Chrome extension
+
+ - Click the extension icon on a tab — it turns green
+ - Install CLI and run your first command:
+
-# 2. Click the extension icon on a tab — it turns green
+ {dedent`
+ npm i -g playwriter
+ playwriter -s 1 -e "await page.goto('https://example.com')"
+ `}
-# 3. Install CLI and run your first command
-npm i -g playwriter
-playwriter -s 1 -e "await page.goto('https://example.com')"`}
+
+ The extension connects your browser to a local WebSocket relay on{" "}
+ localhost:19988. The CLI sends Playwright
+ code through the relay. No remote servers, no accounts, nothing
+ leaves your machine.
+
-
- The extension connects your browser to a local WebSocket relay on{" "}
- localhost:19988. The CLI sends Playwright
- code through the relay. No remote servers, no accounts, nothing
- leaves your machine.
-
+ {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()"
+ `}
- {`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()"`}
-
-
-
- Extension icon green = connected. Gray = not attached to this tab.
-
+
+
+ Extension icon green = connected. Gray = not attached to this tab.
+
-
- The extension uses chrome.debugger to
- attach to tabs where you clicked the icon. It opens a WebSocket
- connection to a local relay server. The CLI (or MCP, or your own
- Playwright script) connects to the same relay. CDP commands flow
- through; the extension forwards them to Chrome and sends responses
- back.
-
+
+ The extension uses chrome.debugger to
+ attach to tabs where you clicked the icon. It opens a WebSocket
+ connection to a local relay server. The CLI (or MCP, or your own
+ Playwright script) connects to the same relay. CDP commands flow
+ through; the extension forwards them to Chrome and sends responses
+ back.
+
- {`┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
-│ BROWSER │ │ LOCALHOST │ │ CLIENT │
-│ │ │ │ │ │
-│ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │
-│ │ Extension │<───────┬───> :19988 │ │ │ CLI / MCP │ │
-│ └───────┬───────┘ │ WS │ │ │ └───────────┘ │
-│ │ │ │ /extension │ │ │ │
-│ chrome.debugger │ │ │ │ │ v │
-│ v │ │ v │ │ ┌────────────┐ │
-│ ┌───────────────┐ │ │ /cdp/:id <───────────────>│ │ execute │ │
-│ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └────────────┘ │
-│ │ Tab 2 (green) │ │ │ │ │
-│ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │
-└─────────────────────┘ (extension not clicked) └─────────────────┘`}
+ {dedent`
+ ┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
+ │ BROWSER │ │ LOCALHOST │ │ CLIENT │
+ │ │ │ │ │ │
+ │ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │
+ │ │ Extension │<───────┬───> :19988 │ │ │ CLI / MCP │ │
+ │ └───────┬───────┘ │ WS │ │ │ └───────────┘ │
+ │ │ │ │ /extension │ │ │ │
+ │ chrome.debugger │ │ │ │ │ v │
+ │ v │ │ v │ │ ┌────────────┐ │
+ │ ┌───────────────┐ │ │ /cdp/:id <───────────────>│ │ execute │ │
+ │ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └────────────┘ │
+ │ │ Tab 2 (green) │ │ │ │ │
+ │ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │
+ └─────────────────────┘ (extension not clicked) └─────────────────┘
+ `}
-
- No Chrome restart required. No --remote-debugging-port{" "}
- flags. The extension handles the CDP attachment transparently, and
- the relay multiplexes sessions so multiple agents or CLI instances
- can work with the same browser simultaneously.
-
+
+ No Chrome restart required. No --remote-debugging-port{" "}
+ flags. The extension handles the CDP attachment transparently, and
+ the relay multiplexes sessions so multiple agents or CLI instances
+ can work with the same browser simultaneously.
+
-
- The core feedback loop is observe → act → observe.
- Accessibility snapshots are the primary way to read page state. They return
- the full interactive element tree as text, with Playwright locators attached
- to every element.
-
+
+ The core feedback loop is observe → act → observe.
+ Accessibility snapshots are the primary way to read page state. They return
+ the full interactive element tree as text, with Playwright locators attached
+ to every element.
+
- {`playwriter -s 1 -e "await snapshot({ page })"
+ {dedent`
+ playwriter -s 1 -e "await snapshot({ page })"
-# Output:
-# - banner:
-# - link "Home" [id="nav-home"]
-# - navigation:
-# - link "Docs" [data-testid="docs-link"]
-# - link "Blog" role=link[name="Blog"]`}
+ # Output:
+ # - banner:
+ # - link "Home" [id="nav-home"]
+ # - navigation:
+ # - link "Docs" [data-testid="docs-link"]
+ # - link "Blog" role=link[name="Blog"]
+ `}
-
- Each line ends with a locator you can pass directly to{" "}
- page.locator(). Subsequent calls return a
- diff, so you only see what changed. Use{" "}
- search to filter large pages.
-
+
+ Each line ends with a locator you can pass directly to{" "}
+ page.locator(). Subsequent calls return a
+ diff, so you only see what changed. Use{" "}
+ search to filter large pages.
+
- {`# Search for specific elements
-playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
+ {dedent`
+ # Search for specific elements
+ playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
-# Always print URL first, then snapshot — pages can redirect
-playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).then(console.log)"`}
+ # Always print URL first, then snapshot — pages can redirect
+ playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).then(console.log)"
+ `}
-
- Snapshots are text. They cost a fraction of what screenshots cost in
- tokens. Use them as your primary debugging tool. Only reach for
- screenshots when spatial layout matters — grids, dashboards, maps.
-
+
+ Snapshots are text. They cost a fraction of what screenshots cost in
+ tokens. Use them as your primary debugging tool. Only reach for
+ screenshots when spatial layout matters — grids, dashboards, maps.
+
-
-
- Accessibility tree as text. 5–20KB vs 100KB+ for screenshots.
-
+
+
+ Accessibility tree as text. 5–20KB vs 100KB+ for screenshots.
+
-
- For pages where spatial layout matters,{" "}
- screenshotWithAccessibilityLabels overlays
- Vimium-style labels on every interactive element. Take a screenshot,
- read the labels, click by reference.
-
+
+ For pages where spatial layout matters,{" "}
+ screenshotWithAccessibilityLabels overlays
+ Vimium-style labels on every interactive element. Take a screenshot,
+ read the labels, click by reference.
+
- {`playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })"
-# Returns screenshot + accessibility snapshot with aria-ref selectors
+ {dedent`
+ playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })"
+ # Returns screenshot + accessibility snapshot with aria-ref selectors
-playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`}
+ playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"
+ `}
-
- Labels are color-coded by element type: 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{" "}
- snapshot(), so you can switch between text
- and visual modes freely.
-
+
+ Labels are color-coded by element type: 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{" "}
+ snapshot(), so you can switch between text
+ and visual modes freely.
+
-
-
- Vimium-style labels. Screenshot + snapshot in one call.
-
+
+
+ Vimium-style labels. Screenshot + snapshot in one call.
+
-
- Each session runs in an isolated sandbox with its own{" "}
- state object. Variables, pages, listeners
- persist between calls within a session. Different sessions get
- different state. Browser tabs are shared.
-
+
+ Each session runs in an isolated sandbox with its own{" "}
+ state object. Variables, pages, listeners
+ persist between calls within a session. Different sessions get
+ different state. Browser tabs are shared.
+
- {`playwriter session new # => 1
-playwriter session new # => 2
-playwriter session list # shows sessions + state keys
+ {dedent`
+ playwriter session new # => 1
+ playwriter session new # => 2
+ playwriter session list # shows sessions + state keys
-# Session 1 stores data
-playwriter -s 1 -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))"
+ # Session 1 stores data
+ playwriter -s 1 -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))"
-# Session 2 can't see it
-playwriter -s 2 -e "console.log(state.users)" # undefined`}
+ # Session 2 can't see it
+ playwriter -s 2 -e "console.log(state.users)" # undefined
+ `}
-
- Create your own page to avoid interference from other agents. Reuse
- an existing about:blank tab or create a
- fresh one, and store it in state.
-
+
+ Create your own page to avoid interference from other agents. Reuse
+ an existing about:blank tab or create a
+ fresh one, and store it in state.
+
- {`playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')"
+ {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())"`}
+ # All subsequent calls use state.myPage
+ playwriter -s 1 -e "console.log(await state.myPage.title())"
+ `}
-
- Full Chrome DevTools Protocol access. Set breakpoints, step through
- code, inspect variables at runtime. Live-edit page scripts and CSS
- without reloading.
-
+
+ Full Chrome DevTools Protocol access. Set breakpoints, step through
+ code, inspect variables at runtime. Live-edit page scripts and CSS
+ without reloading.
+
- {`# 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))"
-playwriter -s 1 -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })"
+ {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))"
+ playwriter -s 1 -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })"
-# Live edit page code
-playwriter -s 1 -e "state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()"
-playwriter -s 1 -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"`}
+ # Live edit page code
+ playwriter -s 1 -e "state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()"
+ playwriter -s 1 -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"
+ `}
-
- Edits are in-memory 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{" "}
- grep across all loaded scripts.
-
+
+ Edits are in-memory 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{" "}
+ grep across all loaded scripts.
+
-
-
- Breakpoints, stepping, variable inspection — from the CLI.
-
+
+
+ Breakpoints, stepping, variable inspection — from the CLI.
+
-
- Intercept requests and responses to reverse-engineer APIs, scrape
- data, or debug network issues. Store captured data in{" "}
- state and analyze across calls.
-
+
+ Intercept requests and responses to reverse-engineer APIs, scrape
+ data, or debug network issues. Store captured data in{" "}
+ state and analyze across calls.
+
- {`# 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 {} } })"
+ {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 {} } })"
-# Trigger actions, then analyze
-playwriter -s 1 -e "await page.click('button.load-more')"
-playwriter -s 1 -e "console.log('Captured', state.responses.length, 'API calls'); state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)))"
+ # Trigger actions, then analyze
+ playwriter -s 1 -e "await page.click('button.load-more')"
+ playwriter -s 1 -e "console.log('Captured', state.responses.length, 'API calls'); state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)))"
-# Replay an API call directly
-playwriter -s 1 -e "const data = await page.evaluate(async (url) => { const res = await fetch(url); return res.json(); }, state.responses[0].url); console.log(data)"`}
+ # Replay an API call directly
+ playwriter -s 1 -e "const data = await page.evaluate(async (url) => { const res = await fetch(url); return res.json(); }, state.responses[0].url); console.log(data)"
+ `}
-
- This is faster than scrolling through DOM. Capture the real API
- calls, inspect their schemas, and replay them with different
- parameters. Works for pagination, authenticated endpoints, and
- anything behind JavaScript rendering.
-
+
+ This is faster than scrolling through DOM. Capture the real API
+ calls, inspect their schemas, and replay them with different
+ parameters. Works for pagination, authenticated endpoints, and
+ anything behind JavaScript rendering.
+
-
- Record the active tab as video using{" "}
- chrome.tabCapture. The recording runs in
- the extension context, so it survives page navigation. Video is saved
- as MP4.
-
+
+ Record the active tab as video using{" "}
+ chrome.tabCapture. The recording runs in
+ the extension context, so it survives page navigation. Video is saved
+ as MP4.
+
- {`# Start recording
-playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })"
+ {dedent`
+ # Start recording
+ playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })"
-# Navigate, interact — recording continues
-playwriter -s 1 -e "await page.click('a'); await page.waitForLoadState('domcontentloaded')"
-playwriter -s 1 -e "await page.goBack()"
+ # Navigate, interact — recording continues
+ playwriter -s 1 -e "await page.click('a'); await page.waitForLoadState('domcontentloaded')"
+ playwriter -s 1 -e "await page.goBack()"
-# Stop and save
-playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')"`}
+ # Stop and save
+ playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')"
+ `}
-
- Unlike getDisplayMedia, this approach
- persists across navigations because the extension holds the{" "}
- MediaRecorder, not the page. You can also
- check recording status with isRecording or
- cancel without saving with cancelRecording.
-
+
+ Unlike getDisplayMedia, this approach
+ persists across navigations because the extension holds the{" "}
+ MediaRecorder, not the page. You can also
+ check recording status with isRecording or
+ cancel without saving with cancelRecording.
+
-
-
- Native tab capture. 30–60fps. Survives navigation.
-
+
+
+ Native tab capture. 30–60fps. Survives navigation.
+
-
- How Playwriter compares to other browser automation approaches.
-
+
+ How Playwriter compares to other browser automation approaches.
+
-
+
-
+
-
+
-
+
-
- Control Chrome on a remote machine over the internet using tunnels.
- Run the relay on the host, expose it through a tunnel, and connect
- from anywhere.
-
+
+ Control Chrome on a remote machine over the internet using tunnels.
+ Run the relay on the host, expose it through a tunnel, and connect
+ from anywhere.
+
- {`# On the host machine
-npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token
+ {dedent`
+ # On the host machine
+ npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token
-# From anywhere
-export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
-export PLAYWRITER_TOKEN=
-playwriter -s 1 -e "await page.goto('https://example.com')"`}
+ # From anywhere
+ export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
+ export PLAYWRITER_TOKEN=
+ playwriter -s 1 -e "await page.goto('https://example.com')"
+ `}
-
- Also works on a LAN without tunnels — just set{" "}
- PLAYWRITER_HOST=192.168.1.10. Use cases
- include controlling a headless Mac mini, providing remote user
- support, and multi-machine automation.
-
+
+ Also works on a LAN without tunnels — just set{" "}
+ PLAYWRITER_HOST=192.168.1.10. Use cases
+ include controlling a headless Mac mini, providing remote user
+ support, and multi-machine automation.
+
-
- Playwriter is local by default. The relay runs on{" "}
- localhost:19988 and only accepts connections
- from the extension. There's no remote server, no account, no
- telemetry.
-
+
+ Playwriter is local by default. The relay runs on{" "}
+ localhost:19988 and only accepts connections
+ from the extension. There's no remote server, no account, no
+ telemetry.
+
-
- -
- Local only — WebSocket server binds to
- localhost. Nothing leaves your machine.
-
- -
- Origin validation — only the Playwriter
- extension origin is accepted. Browsers cannot spoof the Origin
- header, so malicious websites cannot connect.
-
- -
- Explicit consent — only tabs where you
- clicked the extension icon are controlled. No background access.
-
- -
- Visible automation — Chrome shows an
- automation banner on controlled tabs.
-
-
+
+ -
+ Local only — WebSocket server binds to
+ localhost. Nothing leaves your machine.
+
+ -
+ Origin validation — only the Playwriter
+ extension origin is accepted. Browsers cannot spoof the Origin
+ header, so malicious websites cannot connect.
+
+ -
+ Explicit consent — only tabs where you
+ clicked the extension icon are controlled. No background access.
+
+ -
+ Visible automation — Chrome shows an
+ automation banner on controlled tabs.
+
+
diff --git a/website/src/styles/liveline.css b/website/src/styles/liveline.css
index 5f5a17a..92aa190 100644
--- a/website/src/styles/liveline.css
+++ b/website/src/styles/liveline.css
@@ -79,6 +79,9 @@
rgba(0, 0, 0, 0.04) 0px 4px 16px,
rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
+ /* Link accent color */
+ --ll-accent: #0969da;
+
/* P3 accent colors (wider gamut) */
--ll-primary: #3e9fff;
--ll-secondary: #f09637;
@@ -130,6 +133,7 @@
rgba(255, 255, 255, 0.02) 0px 4px 16px,
rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
+ --ll-accent: #58a6ff;
--ll-primary: #60a5fa;
--ll-secondary: #f5a623;