diff --git a/website/src/components/markdown.tsx b/website/src/components/markdown.tsx new file mode 100644 index 0000000..0b7494c --- /dev/null +++ b/website/src/components/markdown.tsx @@ -0,0 +1,578 @@ +/* + * Editorial markdown components for the liveline-style pages. + * Extracted from liveline.tsx so page files are content-only. + * + * All components use CSS variables from liveline.css (--ll-*). + * Import liveline.css and liveline-prism.css in the page file. + */ + +import { useEffect, useRef, useState } from "react"; +import Prism from "prismjs"; +import "prismjs/components/prism-jsx"; +import "prismjs/components/prism-tsx"; +import "prismjs/components/prism-bash"; + +/* ========================================================================= + TOC sidebar (fixed left) + ========================================================================= */ + +function useActiveTocId() { + const [activeId, setActiveId] = useState(""); + + useEffect(() => { + const headings = document.querySelectorAll("h1[id]"); + if (headings.length === 0) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const visible: string[] = []; + entries.forEach((entry) => { + if (entry.isIntersecting && entry.target.id) { + visible.push(entry.target.id); + } + }); + + if (visible.length > 0) { + const sorted = visible.sort((a, b) => { + const elA = document.getElementById(a); + const elB = document.getElementById(b); + if (!elA || !elB) { + return 0; + } + return elA.getBoundingClientRect().top - elB.getBoundingClientRect().top; + }); + setActiveId(sorted[sorted.length - 1]); + } + }, + { + rootMargin: "-80px 0px -75% 0px", + threshold: 0, + }, + ); + + headings.forEach((heading) => { + observer.observe(heading); + }); + + return () => { + observer.disconnect(); + }; + }, []); + + return activeId; +} + +export function TableOfContents({ + items, + logo, +}: { + items: Array<{ label: string; href: string }>; + logo?: string; +}) { + const activeId = useActiveTocId(); + + return ( + + ); +} + +/* ========================================================================= + Back button (fixed top-right) + ========================================================================= */ + +export function BackButton() { + return ( + { + e.currentTarget.style.color = "var(--ll-text-hover)"; + e.currentTarget.style.transform = "scale(1.05)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = "var(--ll-text-secondary)"; + e.currentTarget.style.transform = "scale(1)"; + }} + onMouseDown={(e) => { + e.currentTarget.style.transform = "scale(0.95)"; + }} + onMouseUp={(e) => { + e.currentTarget.style.transform = "scale(1.05)"; + }} + > + + + + + ); +} + +/* ========================================================================= + Typography + ========================================================================= */ + +export function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function P({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return ( +

+ {children} +

+ ); +} + +export function Caption({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function Code({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/* ========================================================================= + Layout + ========================================================================= */ + +export function Divider() { + return ( +
+
+
+ ); +} + +export function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) { + return ( + <> + + {title} + {children} + + ); +} + +export function List({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
+ ); +} + +export function Li({ children }: { children: React.ReactNode }) { + return
  • {children}
  • ; +} + +/* ========================================================================= + Code block with Prism syntax highlighting and line numbers + ========================================================================= */ + +export function CodeBlock({ children, lang = "jsx" }: { children: string; lang?: string }) { + const codeRef = useRef(null); + const lines = children.split("\n"); + + useEffect(() => { + if (codeRef.current) { + Prism.highlightElement(codeRef.current); + } + }, [children]); + + return ( +
    +
    +
    +          
    + + + {children} + +
    +
    +
    +
    + ); +} + +/* ========================================================================= + Chart placeholder (dark box with animated line) + ========================================================================= */ + +export function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) { + return ( +
    +
    + + + + + + + + + + + + + + + {label && ( +
    + {label} +
    + )} +
    +
    + ); +} + +/* ========================================================================= + Comparison table + ========================================================================= */ + +export function PropsTable({ + title, + rows, +}: { + title?: string; + rows: Array<[string, string, string]>; +}) { + return ( +
    + {title && ( +
    + {title} +
    + )} + + + + {["Prop", "Type", "Default"].map((header) => { + return ( + + ); + })} + + + + {rows.map(([prop, type, def]) => { + return ( + + + + + + ); + })} + +
    + {header} +
    + {prop} + + {type} + + {def} +
    +
    + ); +} + +/* ========================================================================= + Page shell — wraps content with layout, TOC, back button, animations + ========================================================================= */ + +export function EditorialPage({ + toc, + logo, + children, +}: { + toc: Array<{ label: string; href: string }>; + logo?: string; + children: React.ReactNode; +}) { + return ( +
    + + + +
    +
    + +
    + {children} +
    +
    +
    + ); +} diff --git a/website/src/routes/liveline.tsx b/website/src/routes/liveline.tsx index 0ed066f..d9632be 100644 --- a/website/src/routes/liveline.tsx +++ b/website/src/routes/liveline.tsx @@ -1,82 +1,23 @@ /* - * Playwriter editorial page — benji.org/liveline-inspired design. - * Uses the same editorial layout, typography, and styling system. - * ChartPlaceholder boxes reserved for future showcase videos. - * - * Prism.js is used for syntax highlighting with a custom light theme - * that matches the original benji.org subtle code block style. + * Playwriter editorial page — content only. + * Components imported from website/src/components/markdown.tsx. + * Styles from liveline.css and liveline-prism.css. */ -import { useEffect, useRef, useState } from "react"; -import Prism from "prismjs"; -import "prismjs/components/prism-jsx"; -import "prismjs/components/prism-tsx"; -import "prismjs/components/prism-bash"; import "website/src/styles/liveline.css"; import "website/src/styles/liveline-prism.css"; - - -/* ========================================================================= - TOC sidebar (fixed left) - ========================================================================= */ - -/* - * Tracks which section heading is currently in the top ~25% of the viewport. - * Uses IntersectionObserver with rootMargin to create a detection zone: - * -80px top (fixed header offset), -75% bottom (only top quarter triggers). - * When multiple headings intersect, the last one in DOM order wins since - * that's the section the user is actually reading. - */ -function useActiveTocId() { - const [activeId, setActiveId] = useState(""); - - useEffect(() => { - const headings = document.querySelectorAll("h1[id]"); - if (headings.length === 0) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - // Collect all currently-intersecting heading ids - const visible: string[] = []; - entries.forEach((entry) => { - if (entry.isIntersecting && entry.target.id) { - visible.push(entry.target.id); - } - }); - - if (visible.length > 0) { - // Pick the last one in DOM order (furthest down the page) - const sorted = visible.sort((a, b) => { - const elA = document.getElementById(a); - const elB = document.getElementById(b); - if (!elA || !elB) { - return 0; - } - return elA.getBoundingClientRect().top - elB.getBoundingClientRect().top; - }); - setActiveId(sorted[sorted.length - 1]); - } - }, - { - // 80px for fixed header, bottom -75% so only top quarter triggers - rootMargin: "-80px 0px -75% 0px", - threshold: 0, - }, - ); - - headings.forEach((heading) => { - observer.observe(heading); - }); - - return () => { - observer.disconnect(); - }; - }, []); - - return activeId; -} +import { + EditorialPage, + P, + Code, + Caption, + CodeBlock, + ChartPlaceholder, + Section, + PropsTable, + List, + Li, +} from "website/src/components/markdown"; const tocItems = [ { label: "Getting started", href: "#getting-started" }, @@ -92,528 +33,37 @@ const tocItems = [ { label: "Security", href: "#security" }, ]; -function TableOfContents() { - const activeId = useActiveTocId(); - - return ( - - ); -} - -/* ========================================================================= - Back button (fixed top-right) - ========================================================================= */ - -function BackButton() { - return ( - { - e.currentTarget.style.color = "var(--ll-text-hover)"; - e.currentTarget.style.transform = "scale(1.05)"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = "var(--ll-text-secondary)"; - e.currentTarget.style.transform = "scale(1)"; - }} - onMouseDown={(e) => { - e.currentTarget.style.transform = "scale(0.95)"; - }} - onMouseUp={(e) => { - e.currentTarget.style.transform = "scale(1.05)"; - }} - > - - - - - ); -} - -/* ========================================================================= - Reusable components - ========================================================================= */ - -function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) { - return ( -

    - {children} -

    - ); -} - -function Paragraph({ children, className = "" }: { children: React.ReactNode; className?: string }) { - return ( -

    - {children} -

    - ); -} - -function Caption({ children }: { children: React.ReactNode }) { - return ( -

    - {children} -

    - ); -} - -function Divider() { - return ( -
    - ); -} - -function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) { - return ( - <> - - {title} - {children} - - ); -} - -function List({ children }: { children: React.ReactNode }) { - return ( -
      - {children} -
    - ); -} - -function Li({ children }: { children: React.ReactNode }) { - return
  • {children}
  • ; -} - -function CodeBlock({ children, lang = "jsx" }: { children: string; lang?: string }) { - const codeRef = useRef(null); - const lines = children.split("\n"); - - useEffect(() => { - if (codeRef.current) { - Prism.highlightElement(codeRef.current); - } - }, [children]); - - return ( -
    -
    -
    -          
    - {/* Line numbers */} - - {/* Highlighted code */} - - {children} - -
    -
    -
    -
    - ); -} - -function InlineCode({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) { - return ( -
    -
    - {/* Simulated chart line */} - - - - - - - - - - {/* Live dot */} - - - - - - {label && ( -
    - {label} -
    - )} -
    -
    - ); -} - -function PropsTable({ - title, - rows, -}: { - title?: string; - rows: Array<[string, string, string]>; -}) { - return ( -
    - {title && ( -
    - {title} -
    - )} - - - - {["Prop", "Type", "Default"].map((header) => { - return ( - - ); - })} - - - - {rows.map(([prop, type, def]) => { - return ( - - - - - - ); - })} - -
    - {header} -
    - {prop} - - {type} - - {def} -
    -
    - ); -} - -/* ========================================================================= - Main page - ========================================================================= */ - export default function LivelinePage() { return ( -
    - - + -
    - {/* Top spacer */} -
    +

    + 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. +

    -
    - {/* Intro */} - - 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. - + + + Your existing Chrome session. Extensions, logins, cookies — all there. + - - - Your existing Chrome session. Extensions, logins, cookies — all there. - +

    + Every browser automation MCP I tried either spawns a new Chrome + instance or forces you into a limited set of predefined tools. Playwriter + does neither. It connects to the browser you already have open, + exposes the full Playwright API through a single{" "} + execute tool, and gets out of the way. + One tool. Any Playwright code. No wrappers. +

    - - Every browser automation MCP I tried either spawns a new Chrome - instance or forces you into a limited set of predefined tools. Playwriter - does neither. It connects to the browser you already have open, - exposes the full Playwright API through a single{" "} - execute tool, and gets out of the way. - One tool. Any Playwright code. No wrappers. - +
    -
    +

    + Three steps. Extension, icon click, then you're automating. +

    - - Three steps. Extension, icon click, then you're automating. - - - {`# 1. Install the Chrome extension + {`# 1. Install the Chrome extension # https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe # 2. Click the extension icon on a tab — it turns green @@ -622,71 +72,70 @@ export default function LivelinePage() { 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. +

    - {`playwriter session new # new sandbox, outputs id (e.g. 1) + {`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 │ +│ ┌───────────────┐ │ │ 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 })" + {`playwriter -s 1 -e "await snapshot({ page })" # Output: # - banner: @@ -695,71 +144,71 @@ playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`} # - 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 + {`# 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)"`} - - 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 })" + {`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()"`} - - 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 # => 1 playwriter session new # => 2 playwriter session list # shows sessions + state keys @@ -769,28 +218,28 @@ playwriter -s 1 -e "state.users = await page.$$eval('.user', els => els.map(e => # 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')" + {`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())"`} -
    +
    -
    +
    - - 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 + {`# 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 })" @@ -799,29 +248,29 @@ playwriter -s 1 -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, 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 + {`# 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 @@ -831,25 +280,25 @@ playwriter -s 1 -e "console.log('Captured', state.responses.length, 'API calls') # 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 + {`# Start recording playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })" # Navigate, interact — recording continues @@ -859,82 +308,82 @@ 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')"`} - - 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 + {`# On the host machine npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token # From anywhere @@ -942,47 +391,46 @@ 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. +
  • +
    -
    -
    -
    -
    + + +
    ); }