extract editorial components to markdown.tsx, simplify liveline to content-only

- New website/src/components/markdown.tsx with all reusable components:
  EditorialPage, TableOfContents, BackButton, P, Code, Caption, CodeBlock,
  ChartPlaceholder, Section, Divider, SectionHeading, List, Li, PropsTable
- liveline.tsx reduced from ~988 to ~370 lines, now pure content
- Renamed Paragraph->P, InlineCode->Code for brevity
- TOC accepts items/logo props for reuse across pages
- Centered image captions
- Divider with equal top/bottom padding
- Variable spacing CSS rules for code blocks, images, section dividers
This commit is contained in:
Tommy D. Rossi
2026-02-20 12:38:54 +01:00
parent 23da0767e9
commit 984f41cfbe
2 changed files with 855 additions and 829 deletions
+578
View File
@@ -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<HTMLElement>("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 (
<aside
className="fixed top-[80px] hidden lg:block"
style={{ left: "max(1rem, calc((100vw - 550px) / 2 - 200px))", width: "122px" }}
>
<nav>
<a
href="/"
className="no-underline transition-colors block"
style={{
fontSize: "14px",
fontWeight: 700,
lineHeight: "20px",
letterSpacing: "-0.09px",
padding: "4px 0",
color: "var(--ll-text-primary)",
fontFamily: "var(--ll-font-primary)",
marginBottom: "8px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--ll-text-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--ll-text-primary)";
}}
>
{logo ?? "index"}
</a>
{items.map((item) => {
const isActive = `#${activeId}` === item.href;
const defaultColor = isActive ? "var(--ll-text-primary)" : "var(--ll-text-secondary)";
return (
<a
key={item.href}
href={item.href}
className="block no-underline"
style={{
fontSize: "13px",
fontWeight: 475,
lineHeight: "15.6px",
letterSpacing: "-0.04px",
padding: "3px 0",
color: defaultColor,
fontFamily: "var(--ll-font-primary)",
transition: "color 0.15s ease",
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.color = "var(--ll-text-hover)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = defaultColor;
}}
>
{item.label}
</a>
);
})}
</nav>
</aside>
);
}
/* =========================================================================
Back button (fixed top-right)
========================================================================= */
export function BackButton() {
return (
<a
href="/"
className="fixed top-5 right-5 z-[100000] flex items-center justify-center w-10 h-10 rounded-full no-underline"
style={{
background: "var(--ll-btn-bg)",
color: "var(--ll-text-secondary)",
boxShadow: "var(--ll-btn-shadow)",
transition: "color 0.15s, transform 0.15s",
}}
onMouseEnter={(e) => {
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)";
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M12.25 7H1.75M1.75 7L6.125 2.625M1.75 7L6.125 11.375"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
);
}
/* =========================================================================
Typography
========================================================================= */
export function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) {
return (
<h1
id={id}
className="scroll-mt-[5.25rem]"
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 560,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
margin: 0,
padding: 0,
transform: "translateY(-10px)",
}}
>
{children}
</h1>
);
}
export function P({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<p
className={className}
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
margin: 0,
}}
>
{children}
</p>
);
}
export function Caption({ children }: { children: React.ReactNode }) {
return (
<p
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "12px",
fontWeight: 475,
textAlign: "center",
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-secondary)",
margin: 0,
}}
>
{children}
</p>
);
}
export function Code({ children }: { children: React.ReactNode }) {
return (
<code className="ll-inline-code">
{children}
</code>
);
}
/* =========================================================================
Layout
========================================================================= */
export function Divider() {
return (
<div style={{ padding: "24px 0", display: "flex", alignItems: "center" }}>
<div style={{ height: "1px", background: "var(--ll-divider)", flex: 1 }} />
</div>
);
}
export function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<>
<Divider />
<SectionHeading id={id}>{title}</SectionHeading>
{children}
</>
);
}
export function List({ children }: { children: React.ReactNode }) {
return (
<ul
className="m-0 pl-5"
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
listStyleType: "disc",
}}
>
{children}
</ul>
);
}
export function Li({ children }: { children: React.ReactNode }) {
return <li style={{ padding: "0 0 8px 12px" }}>{children}</li>;
}
/* =========================================================================
Code block with Prism syntax highlighting and line numbers
========================================================================= */
export function CodeBlock({ children, lang = "jsx" }: { children: string; lang?: string }) {
const codeRef = useRef<HTMLElement>(null);
const lines = children.split("\n");
useEffect(() => {
if (codeRef.current) {
Prism.highlightElement(codeRef.current);
}
}, [children]);
return (
<figure className="m-0 ll-bleed">
<div className="relative">
<pre
className="overflow-x-auto"
style={{
background: "var(--ll-code-bg)",
borderRadius: "8px",
margin: 0,
padding: 0,
}}
>
<div
className="flex"
style={{
padding: "12px 8px 8px",
fontFamily: "var(--ll-font-code)",
fontSize: "12px",
fontWeight: 400,
lineHeight: "18px",
letterSpacing: "normal",
color: "var(--ll-text-primary)",
tabSize: 2,
}}
>
<span
className="select-none shrink-0"
aria-hidden="true"
style={{
color: "var(--ll-code-line-nr)",
textAlign: "right",
paddingRight: "20px",
minWidth: "28px",
userSelect: "none",
}}
>
{lines.map((_, i) => {
return (
<span key={i} className="block">
{i + 1}
</span>
);
})}
</span>
<code
ref={codeRef}
className={`language-${lang}`}
style={{ whiteSpace: "pre", background: "none", padding: 0 }}
>
{children}
</code>
</div>
</pre>
</div>
</figure>
);
}
/* =========================================================================
Chart placeholder (dark box with animated line)
========================================================================= */
export function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) {
return (
<div className="ll-bleed">
<div
className="w-full overflow-hidden relative"
style={{
height: `${height}px`,
background: "rgb(17, 17, 17)",
}}
>
<svg
viewBox="0 0 550 200"
className="absolute inset-0 w-full h-full"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60"
fill="none"
stroke="#3b82f6"
strokeWidth="2"
/>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60 L550,200 L0,200 Z"
fill="url(#chartFill)"
/>
<circle cx="550" cy="60" r="4" fill="#3b82f6">
<animate attributeName="r" values="4;6;4" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="1;0.6;1" dur="2s" repeatCount="indefinite" />
</circle>
</svg>
{label && (
<div
className="absolute top-3 right-3 px-2 py-1 rounded text-xs"
style={{
background: "rgba(59, 130, 246, 0.15)",
color: "#3b82f6",
fontFamily: "var(--ll-font-code)",
fontWeight: 475,
fontSize: "11px",
}}
>
{label}
</div>
)}
</div>
</div>
);
}
/* =========================================================================
Comparison table
========================================================================= */
export function PropsTable({
title,
rows,
}: {
title?: string;
rows: Array<[string, string, string]>;
}) {
return (
<div className="w-full max-w-full overflow-x-auto" style={{ padding: "8px 0" }}>
{title && (
<div
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "11px",
fontWeight: 400,
color: "var(--ll-text-muted)",
textTransform: "uppercase",
letterSpacing: "0.02em",
padding: "0 0 6px",
}}
>
{title}
</div>
)}
<table
className="w-full"
style={{
borderSpacing: 0,
borderCollapse: "collapse",
}}
>
<thead>
<tr>
{["Prop", "Type", "Default"].map((header) => {
return (
<th
key={header}
className="text-left"
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-primary)",
color: "var(--ll-text-muted)",
borderBottom: "1px solid var(--ll-border)",
}}
>
{header}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map(([prop, type, def]) => {
return (
<tr key={prop}>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "var(--ll-text-primary)",
borderBottom: "1px solid var(--ll-border)",
whiteSpace: "nowrap",
}}
>
{prop}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "var(--ll-text-muted)",
borderBottom: "1px solid var(--ll-border)",
whiteSpace: "nowrap",
}}
>
{type}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "var(--ll-text-muted)",
borderBottom: "1px solid var(--ll-border)",
}}
>
{def}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/* =========================================================================
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 (
<div
className="liveline-page relative min-h-screen overflow-x-hidden"
style={{
background: "var(--ll-bg)",
color: "var(--ll-text-primary)",
fontFamily: "var(--ll-font-primary)",
WebkitFontSmoothing: "antialiased",
textRendering: "optimizeLegibility",
}}
>
<BackButton />
<TableOfContents items={toc} logo={logo} />
<div
className="mx-auto"
style={{ width: "550px", maxWidth: "calc(100% - 2rem)", padding: "0 1rem 6rem" }}
>
<div style={{ height: "80px" }} />
<article className="liveline-article flex flex-col gap-[24px]">
{children}
</article>
</div>
</div>
);
}
+89 -641
View File
@@ -1,82 +1,23 @@
/* /*
* Playwriter editorial page — benji.org/liveline-inspired design. * Playwriter editorial page — content only.
* Uses the same editorial layout, typography, and styling system. * Components imported from website/src/components/markdown.tsx.
* ChartPlaceholder boxes reserved for future showcase videos. * Styles from liveline.css and liveline-prism.css.
*
* Prism.js is used for syntax highlighting with a custom light theme
* that matches the original benji.org subtle code block style.
*/ */
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.css";
import "website/src/styles/liveline-prism.css"; import "website/src/styles/liveline-prism.css";
import {
EditorialPage,
/* ========================================================================= P,
TOC sidebar (fixed left) Code,
========================================================================= */ Caption,
CodeBlock,
/* ChartPlaceholder,
* Tracks which section heading is currently in the top ~25% of the viewport. Section,
* Uses IntersectionObserver with rootMargin to create a detection zone: PropsTable,
* -80px top (fixed header offset), -75% bottom (only top quarter triggers). List,
* When multiple headings intersect, the last one in DOM order wins since Li,
* that's the section the user is actually reading. } from "website/src/components/markdown";
*/
function useActiveTocId() {
const [activeId, setActiveId] = useState("");
useEffect(() => {
const headings = document.querySelectorAll<HTMLElement>("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;
}
const tocItems = [ const tocItems = [
{ label: "Getting started", href: "#getting-started" }, { label: "Getting started", href: "#getting-started" },
@@ -92,526 +33,35 @@ const tocItems = [
{ label: "Security", href: "#security" }, { label: "Security", href: "#security" },
]; ];
function TableOfContents() {
const activeId = useActiveTocId();
return (
<aside
className="fixed top-[80px] hidden lg:block"
style={{ left: "max(1rem, calc((100vw - 550px) / 2 - 200px))", width: "122px" }}
>
<nav>
<a
href="/"
className="no-underline transition-colors block"
style={{
fontSize: "14px",
fontWeight: 700,
lineHeight: "20px",
letterSpacing: "-0.09px",
padding: "4px 0",
color: "var(--ll-text-primary)",
fontFamily: "var(--ll-font-primary)",
marginBottom: "8px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--ll-text-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--ll-text-primary)";
}}
>
playwriter
</a>
{tocItems.map((item) => {
const isActive = `#${activeId}` === item.href;
const defaultColor = isActive ? "var(--ll-text-primary)" : "var(--ll-text-secondary)";
return (
<a
key={item.href}
href={item.href}
className="block no-underline"
style={{
fontSize: "13px",
fontWeight: 475,
lineHeight: "15.6px",
letterSpacing: "-0.04px",
padding: "3px 0",
color: defaultColor,
fontFamily: "var(--ll-font-primary)",
transition: "color 0.15s ease",
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.color = "var(--ll-text-hover)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = defaultColor;
}}
>
{item.label}
</a>
);
})}
</nav>
</aside>
);
}
/* =========================================================================
Back button (fixed top-right)
========================================================================= */
function BackButton() {
return (
<a
href="/"
className="fixed top-5 right-5 z-[100000] flex items-center justify-center w-10 h-10 rounded-full no-underline"
style={{
background: "var(--ll-btn-bg)",
color: "var(--ll-text-secondary)",
boxShadow: "var(--ll-btn-shadow)",
transition: "color 0.15s, transform 0.15s",
}}
onMouseEnter={(e) => {
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)";
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M12.25 7H1.75M1.75 7L6.125 2.625M1.75 7L6.125 11.375"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
);
}
/* =========================================================================
Reusable components
========================================================================= */
function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) {
return (
<h1
id={id}
className="scroll-mt-[5.25rem]"
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 560,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
margin: 0,
padding: 0,
transform: "translateY(-10px)",
}}
>
{children}
</h1>
);
}
function Paragraph({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<p
className={className}
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
margin: 0,
}}
>
{children}
</p>
);
}
function Caption({ children }: { children: React.ReactNode }) {
return (
<p
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "12px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-secondary)",
margin: 0,
}}
>
{children}
</p>
);
}
function Divider() {
return (
<div
style={{
height: "1px",
background: "var(--ll-divider)",
margin: 0,
}}
/>
);
}
function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<>
<Divider />
<SectionHeading id={id}>{title}</SectionHeading>
{children}
</>
);
}
function List({ children }: { children: React.ReactNode }) {
return (
<ul
className="m-0 pl-5"
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
listStyleType: "disc",
}}
>
{children}
</ul>
);
}
function Li({ children }: { children: React.ReactNode }) {
return <li style={{ padding: "0 0 8px 12px" }}>{children}</li>;
}
function CodeBlock({ children, lang = "jsx" }: { children: string; lang?: string }) {
const codeRef = useRef<HTMLElement>(null);
const lines = children.split("\n");
useEffect(() => {
if (codeRef.current) {
Prism.highlightElement(codeRef.current);
}
}, [children]);
return (
<figure className="m-0 ll-bleed">
<div className="relative">
<pre
className="overflow-x-auto"
style={{
background: "var(--ll-code-bg)",
borderRadius: "8px",
margin: 0,
padding: 0,
}}
>
<div
className="flex"
style={{
padding: "12px 8px 8px",
fontFamily: "var(--ll-font-code)",
fontSize: "12px",
fontWeight: 400,
lineHeight: "18px",
letterSpacing: "normal",
color: "var(--ll-text-primary)",
tabSize: 2,
}}
>
{/* Line numbers */}
<span
className="select-none shrink-0"
aria-hidden="true"
style={{
color: "var(--ll-code-line-nr)",
textAlign: "right",
paddingRight: "20px",
minWidth: "28px",
userSelect: "none",
}}
>
{lines.map((_, i) => {
return (
<span key={i} className="block">
{i + 1}
</span>
);
})}
</span>
{/* Highlighted code */}
<code
ref={codeRef}
className={`language-${lang}`}
style={{ whiteSpace: "pre", background: "none", padding: 0 }}
>
{children}
</code>
</div>
</pre>
</div>
</figure>
);
}
function InlineCode({ children }: { children: React.ReactNode }) {
return (
<code className="ll-inline-code">
{children}
</code>
);
}
function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) {
return (
<div className="my-4 ll-bleed">
<div
className="w-full overflow-hidden relative"
style={{
height: `${height}px`,
background: "rgb(17, 17, 17)",
}}
>
{/* Simulated chart line */}
<svg
viewBox="0 0 550 200"
className="absolute inset-0 w-full h-full"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60"
fill="none"
stroke="#3b82f6"
strokeWidth="2"
/>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60 L550,200 L0,200 Z"
fill="url(#chartFill)"
/>
{/* Live dot */}
<circle cx="550" cy="60" r="4" fill="#3b82f6">
<animate
attributeName="r"
values="4;6;4"
dur="2s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="1;0.6;1"
dur="2s"
repeatCount="indefinite"
/>
</circle>
</svg>
{label && (
<div
className="absolute top-3 right-3 px-2 py-1 rounded text-xs"
style={{
background: "rgba(59, 130, 246, 0.15)",
color: "#3b82f6",
fontFamily: "var(--ll-font-code)",
fontWeight: 475,
fontSize: "11px",
}}
>
{label}
</div>
)}
</div>
</div>
);
}
function PropsTable({
title,
rows,
}: {
title?: string;
rows: Array<[string, string, string]>;
}) {
return (
<div className="w-full max-w-full overflow-x-auto" style={{ padding: "8px 0" }}>
{title && (
<div
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "11px",
fontWeight: 400,
color: "var(--ll-text-muted)",
textTransform: "uppercase",
letterSpacing: "0.02em",
padding: "0 0 6px",
}}
>
{title}
</div>
)}
<table
className="w-full"
style={{
borderSpacing: 0,
borderCollapse: "collapse",
}}
>
<thead>
<tr>
{["Prop", "Type", "Default"].map((header) => {
return (
<th
key={header}
className="text-left"
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-primary)",
color: "var(--ll-text-muted)",
borderBottom: "1px solid var(--ll-border)",
}}
>
{header}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map(([prop, type, def]) => {
return (
<tr key={prop}>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "var(--ll-text-primary)",
borderBottom: "1px solid var(--ll-border)",
whiteSpace: "nowrap",
}}
>
{prop}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "var(--ll-text-muted)",
borderBottom: "1px solid var(--ll-border)",
whiteSpace: "nowrap",
}}
>
{type}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "var(--ll-text-muted)",
borderBottom: "1px solid var(--ll-border)",
}}
>
{def}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/* =========================================================================
Main page
========================================================================= */
export default function LivelinePage() { export default function LivelinePage() {
return ( return (
<div <EditorialPage toc={tocItems} logo="playwriter">
className="liveline-page relative min-h-screen overflow-x-hidden"
style={{
background: "var(--ll-bg)",
color: "var(--ll-text-primary)",
fontFamily: "var(--ll-font-primary)",
WebkitFontSmoothing: "antialiased",
textRendering: "optimizeLegibility",
}}
>
<BackButton />
<TableOfContents />
<div <P>
className="mx-auto"
style={{ width: "550px", maxWidth: "calc(100% - 2rem)", padding: "0 1rem 6rem" }}
>
{/* Top spacer */}
<div style={{ height: "80px" }} />
<article className="liveline-article flex flex-col gap-[16px]">
{/* Intro */}
<Paragraph>
Playwriter lets you control your Chrome browser with the full Playwriter lets you control your Chrome browser with the full
Playwright API. A Chrome extension, a local relay, and a CLI. No new 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.
</Paragraph> </P>
<ChartPlaceholder height={300} label="demo" /> <ChartPlaceholder height={300} label="demo" />
<Caption> <Caption>
Your existing Chrome session. Extensions, logins, cookies &mdash; all there. Your existing Chrome session. Extensions, logins, cookies &mdash; all there.
</Caption> </Caption>
<Paragraph> <P>
Every browser automation MCP I tried either spawns a new Chrome Every browser automation MCP I tried either spawns a new Chrome
instance or forces you into a limited set of predefined tools. Playwriter instance or forces you into a limited set of predefined tools. Playwriter
does neither. It connects to the browser you already have open, does neither. It connects to the browser you already have open,
exposes the full Playwright API through a single{" "} exposes the full Playwright API through a single{" "}
<InlineCode>execute</InlineCode> tool, and gets out of the way. <Code>execute</Code> tool, and gets out of the way.
One tool. Any Playwright code. No wrappers. One tool. Any Playwright code. No wrappers.
</Paragraph> </P>
<Section id="getting-started" title="Getting started"> <Section id="getting-started" title="Getting started">
<Paragraph> <P>
Three steps. Extension, icon click, then you&apos;re automating. Three steps. Extension, icon click, then you&apos;re automating.
</Paragraph> </P>
<CodeBlock lang="bash">{`# 1. Install the Chrome extension <CodeBlock lang="bash">{`# 1. Install the Chrome extension
# https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe # https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe
@@ -622,12 +72,12 @@ export default function LivelinePage() {
npm i -g playwriter npm i -g playwriter
playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock> playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock>
<Paragraph> <P>
The extension connects your browser to a local WebSocket relay on{" "} The extension connects your browser to a local WebSocket relay on{" "}
<InlineCode>localhost:19988</InlineCode>. The CLI sends Playwright <Code>localhost:19988</Code>. The CLI sends Playwright
code through the relay. No remote servers, no accounts, nothing code through the relay. No remote servers, no accounts, nothing
leaves your machine. leaves your machine.
</Paragraph> </P>
<CodeBlock lang="bash">{`playwriter session new # new sandbox, outputs id (e.g. 1) <CodeBlock lang="bash">{`playwriter session new # new sandbox, outputs id (e.g. 1)
playwriter -s 1 -e "await page.goto('https://example.com')" playwriter -s 1 -e "await page.goto('https://example.com')"
@@ -643,48 +93,47 @@ playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`}</CodeBlock>
<Section id="how-it-works" title="How it works"> <Section id="how-it-works" title="How it works">
<Paragraph> <P>
The extension uses <InlineCode>chrome.debugger</InlineCode> to The extension uses <Code>chrome.debugger</Code> to
attach to tabs where you clicked the icon. It opens a WebSocket 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 connection to a local relay server. The CLI (or MCP, or your own
Playwright script) connects to the same relay. CDP commands flow Playwright script) connects to the same relay. CDP commands flow
through; the extension forwards them to Chrome and sends responses through; the extension forwards them to Chrome and sends responses
back. back.
</Paragraph> </P>
<CodeBlock lang="bash">{` <CodeBlock lang="bash">{`┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────┐
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ BROWSER │ │ LOCALHOST │ │ CLIENT │ │ BROWSER │ │ LOCALHOST │ │ CLIENT │
│ │ │ │ │ │ │ │ │ │ │ │
│ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │ │ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │
│ │ Extension │<───────┬───> :19988 │ │ │ CLI / MCP │ │ │ Extension │<-----------> :19988 │ │ │ CLI / MCP │ │
│ └───────┬───────┘ │ WS │ │ │ └───────────┘ │ │ └───────┬───────┘ │ WS │ │ │ └───────────┘ │
│ │ │ │ /extension │ │ │ │ │ │ │ │ /extension │ │ │ │
│ chrome.debugger │ │ │ │ │ v │ │ chrome.debugger │ │ │ │ │ v │
│ v │ │ v │ │ ┌───────────┐ │ │ v │ │ v │ │ ┌───────────┐ │
│ ┌───────────────┐ │ │ /cdp/:id <───────────────>│ │ execute │ │ │ ┌───────────────┐ │ │ /cdp/:id <--------------->│ │ execute │ │
│ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └───────────┘ │ │ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └───────────┘ │
│ │ Tab 2 (green) │ │ │ │ │ │ │ Tab 2 (green) │ │ │ │ │
│ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │ │ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │
└─────────────────────┘ (extension not clicked) └─────────────────┘`}</CodeBlock> └─────────────────────┘ (extension not clicked) └─────────────────┘`}</CodeBlock>
<Paragraph> <P>
No Chrome restart required. No <InlineCode>--remote-debugging-port</InlineCode>{" "} No Chrome restart required. No <Code>--remote-debugging-port</Code>{" "}
flags. The extension handles the CDP attachment transparently, and flags. The extension handles the CDP attachment transparently, and
the relay multiplexes sessions so multiple agents or CLI instances the relay multiplexes sessions so multiple agents or CLI instances
can work with the same browser simultaneously. can work with the same browser simultaneously.
</Paragraph> </P>
</Section> </Section>
<Section id="snapshots" title="Accessibility snapshots"> <Section id="snapshots" title="Accessibility snapshots">
<Paragraph> <P>
The core feedback loop is <strong>observe &rarr; act &rarr; observe</strong>. The core feedback loop is <strong>observe &rarr; act &rarr; observe</strong>.
Accessibility snapshots are the primary way to read page state. They return Accessibility snapshots are the primary way to read page state. They return
the full interactive element tree as text, with Playwright locators attached the full interactive element tree as text, with Playwright locators attached
to every element. to every element.
</Paragraph> </P>
<CodeBlock lang="bash">{`playwriter -s 1 -e "await snapshot({ page })" <CodeBlock lang="bash">{`playwriter -s 1 -e "await snapshot({ page })"
@@ -695,12 +144,12 @@ playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`}</CodeBlock>
# - link "Docs" [data-testid="docs-link"] # - link "Docs" [data-testid="docs-link"]
# - link "Blog" role=link[name="Blog"]`}</CodeBlock> # - link "Blog" role=link[name="Blog"]`}</CodeBlock>
<Paragraph> <P>
Each line ends with a locator you can pass directly to{" "} Each line ends with a locator you can pass directly to{" "}
<InlineCode>page.locator()</InlineCode>. Subsequent calls return a <Code>page.locator()</Code>. Subsequent calls return a
diff, so you only see what changed. Use{" "} diff, so you only see what changed. Use{" "}
<InlineCode>search</InlineCode> to filter large pages. <Code>search</Code> to filter large pages.
</Paragraph> </P>
<CodeBlock lang="bash">{`# Search for specific elements <CodeBlock lang="bash">{`# Search for specific elements
playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })" playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
@@ -708,11 +157,11 @@ playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
# Always print URL first, then snapshot — pages can redirect # Always print URL first, then snapshot — pages can redirect
playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).then(console.log)"`}</CodeBlock> playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).then(console.log)"`}</CodeBlock>
<Paragraph> <P>
Snapshots are text. They cost a fraction of what screenshots cost in Snapshots are text. They cost a fraction of what screenshots cost in
tokens. Use them as your primary debugging tool. Only reach for tokens. Use them as your primary debugging tool. Only reach for
screenshots when spatial layout matters &mdash; grids, dashboards, maps. screenshots when spatial layout matters &mdash; grids, dashboards, maps.
</Paragraph> </P>
<ChartPlaceholder height={200} label="snapshot" /> <ChartPlaceholder height={200} label="snapshot" />
<Caption> <Caption>
@@ -723,25 +172,25 @@ playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).th
<Section id="visual-labels" title="Visual labels"> <Section id="visual-labels" title="Visual labels">
<Paragraph> <P>
For pages where spatial layout matters,{" "} For pages where spatial layout matters,{" "}
<InlineCode>screenshotWithAccessibilityLabels</InlineCode> overlays <Code>screenshotWithAccessibilityLabels</Code> overlays
Vimium-style labels on every interactive element. Take a screenshot, Vimium-style labels on every interactive element. Take a screenshot,
read the labels, click by reference. read the labels, click by reference.
</Paragraph> </P>
<CodeBlock lang="bash">{`playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })" <CodeBlock lang="bash">{`playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })"
# Returns screenshot + accessibility snapshot with aria-ref selectors # Returns screenshot + accessibility snapshot with aria-ref selectors
playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`}</CodeBlock> playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`}</CodeBlock>
<Paragraph> <P>
Labels are color-coded by element type: yellow for links, orange for Labels are color-coded by element type: yellow for links, orange for
buttons, coral for inputs, pink for checkboxes, peach for sliders, buttons, coral for inputs, pink for checkboxes, peach for sliders,
salmon for menus, amber for tabs. The ref system is shared with{" "} salmon for menus, amber for tabs. The ref system is shared with{" "}
<InlineCode>snapshot()</InlineCode>, so you can switch between text <Code>snapshot()</Code>, so you can switch between text
and visual modes freely. and visual modes freely.
</Paragraph> </P>
<ChartPlaceholder height={260} label="visual labels" /> <ChartPlaceholder height={260} label="visual labels" />
<Caption> <Caption>
@@ -752,12 +201,12 @@ playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`}</CodeBlock>
<Section id="sessions" title="Sessions"> <Section id="sessions" title="Sessions">
<Paragraph> <P>
Each session runs in an isolated sandbox with its own{" "} Each session runs in an isolated sandbox with its own{" "}
<InlineCode>state</InlineCode> object. Variables, pages, listeners <Code>state</Code> object. Variables, pages, listeners
persist between calls within a session. Different sessions get persist between calls within a session. Different sessions get
different state. Browser tabs are shared. different state. Browser tabs are shared.
</Paragraph> </P>
<CodeBlock lang="bash">{`playwriter session new # => 1 <CodeBlock lang="bash">{`playwriter session new # => 1
playwriter session new # => 2 playwriter session new # => 2
@@ -769,11 +218,11 @@ playwriter -s 1 -e "state.users = await page.$$eval('.user', els => els.map(e =>
# Session 2 can't see it # Session 2 can't see it
playwriter -s 2 -e "console.log(state.users)" # undefined`}</CodeBlock> playwriter -s 2 -e "console.log(state.users)" # undefined`}</CodeBlock>
<Paragraph> <P>
Create your own page to avoid interference from other agents. Reuse Create your own page to avoid interference from other agents. Reuse
an existing <InlineCode>about:blank</InlineCode> tab or create a an existing <Code>about:blank</Code> tab or create a
fresh one, and store it in <InlineCode>state</InlineCode>. fresh one, and store it in <Code>state</Code>.
</Paragraph> </P>
<CodeBlock lang="bash">{`playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')" <CodeBlock lang="bash">{`playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')"
@@ -784,11 +233,11 @@ playwriter -s 1 -e "console.log(await state.myPage.title())"`}</CodeBlock>
<Section id="debugger-and-editor" title="Debugger & editor"> <Section id="debugger-and-editor" title="Debugger & editor">
<Paragraph> <P>
Full Chrome DevTools Protocol access. Set breakpoints, step through Full Chrome DevTools Protocol access. Set breakpoints, step through
code, inspect variables at runtime. Live-edit page scripts and CSS code, inspect variables at runtime. Live-edit page scripts and CSS
without reloading. without reloading.
</Paragraph> </P>
<CodeBlock lang="bash">{`# Set breakpoints and debug <CodeBlock lang="bash">{`# 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.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()"
@@ -799,12 +248,12 @@ 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 "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' })"`}</CodeBlock> playwriter -s 1 -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"`}</CodeBlock>
<Paragraph> <P>
Edits are in-memory and persist until the page reloads. Useful for Edits are in-memory and persist until the page reloads. Useful for
toggling debug flags, patching broken code, or testing quick fixes toggling debug flags, patching broken code, or testing quick fixes
without touching source files. The editor also supports{" "} without touching source files. The editor also supports{" "}
<InlineCode>grep</InlineCode> across all loaded scripts. <Code>grep</Code> across all loaded scripts.
</Paragraph> </P>
<ChartPlaceholder height={200} label="debugger" /> <ChartPlaceholder height={200} label="debugger" />
<Caption> <Caption>
@@ -815,11 +264,11 @@ playwriter -s 1 -e "await state.editor.edit({ url: 'https://example.com/app.js',
<Section id="network-interception" title="Network interception"> <Section id="network-interception" title="Network interception">
<Paragraph> <P>
Intercept requests and responses to reverse-engineer APIs, scrape Intercept requests and responses to reverse-engineer APIs, scrape
data, or debug network issues. Store captured data in{" "} data, or debug network issues. Store captured data in{" "}
<InlineCode>state</InlineCode> and analyze across calls. <Code>state</Code> and analyze across calls.
</Paragraph> </P>
<CodeBlock lang="bash">{`# Start intercepting <CodeBlock lang="bash">{`# 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 {} } })" 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 {} } })"
@@ -831,23 +280,23 @@ playwriter -s 1 -e "console.log('Captured', state.responses.length, 'API calls')
# Replay an API call directly # 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)"`}</CodeBlock> 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)"`}</CodeBlock>
<Paragraph> <P>
This is faster than scrolling through DOM. Capture the real API This is faster than scrolling through DOM. Capture the real API
calls, inspect their schemas, and replay them with different calls, inspect their schemas, and replay them with different
parameters. Works for pagination, authenticated endpoints, and parameters. Works for pagination, authenticated endpoints, and
anything behind JavaScript rendering. anything behind JavaScript rendering.
</Paragraph> </P>
</Section> </Section>
<Section id="screen-recording" title="Screen recording"> <Section id="screen-recording" title="Screen recording">
<Paragraph> <P>
Record the active tab as video using{" "} Record the active tab as video using{" "}
<InlineCode>chrome.tabCapture</InlineCode>. The recording runs in <Code>chrome.tabCapture</Code>. The recording runs in
the extension context, so it survives page navigation. Video is saved the extension context, so it survives page navigation. Video is saved
as MP4. as MP4.
</Paragraph> </P>
<CodeBlock lang="bash">{`# Start recording <CodeBlock lang="bash">{`# Start recording
playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })" playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })"
@@ -859,13 +308,13 @@ playwriter -s 1 -e "await page.goBack()"
# Stop and save # Stop and save
playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')"`}</CodeBlock> playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')"`}</CodeBlock>
<Paragraph> <P>
Unlike <InlineCode>getDisplayMedia</InlineCode>, this approach Unlike <Code>getDisplayMedia</Code>, this approach
persists across navigations because the extension holds the{" "} persists across navigations because the extension holds the{" "}
<InlineCode>MediaRecorder</InlineCode>, not the page. You can also <Code>MediaRecorder</Code>, not the page. You can also
check recording status with <InlineCode>isRecording</InlineCode> or check recording status with <Code>isRecording</Code> or
cancel without saving with <InlineCode>cancelRecording</InlineCode>. cancel without saving with <Code>cancelRecording</Code>.
</Paragraph> </P>
<ChartPlaceholder height={200} label="recording" /> <ChartPlaceholder height={200} label="recording" />
<Caption> <Caption>
@@ -876,9 +325,9 @@ playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page
<Section id="comparison" title="Comparison"> <Section id="comparison" title="Comparison">
<Paragraph> <P>
How Playwriter compares to other browser automation approaches. How Playwriter compares to other browser automation approaches.
</Paragraph> </P>
<PropsTable <PropsTable
title="vs Playwright MCP" title="vs Playwright MCP"
@@ -928,11 +377,11 @@ playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page
<Section id="remote-access" title="Remote access"> <Section id="remote-access" title="Remote access">
<Paragraph> <P>
Control Chrome on a remote machine over the internet using tunnels. Control Chrome on a remote machine over the internet using tunnels.
Run the relay on the host, expose it through a tunnel, and connect Run the relay on the host, expose it through a tunnel, and connect
from anywhere. from anywhere.
</Paragraph> </P>
<CodeBlock lang="bash">{`# On the host machine <CodeBlock lang="bash">{`# On the host machine
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret> npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
@@ -942,23 +391,23 @@ export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=<secret> export PLAYWRITER_TOKEN=<secret>
playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock> playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock>
<Paragraph> <P>
Also works on a LAN without tunnels &mdash; just set{" "} Also works on a LAN without tunnels &mdash; just set{" "}
<InlineCode>PLAYWRITER_HOST=192.168.1.10</InlineCode>. Use cases <Code>PLAYWRITER_HOST=192.168.1.10</Code>. Use cases
include controlling a headless Mac mini, providing remote user include controlling a headless Mac mini, providing remote user
support, and multi-machine automation. support, and multi-machine automation.
</Paragraph> </P>
</Section> </Section>
<Section id="security" title="Security"> <Section id="security" title="Security">
<Paragraph> <P>
Playwriter is local by default. The relay runs on{" "} Playwriter is local by default. The relay runs on{" "}
<InlineCode>localhost:19988</InlineCode> and only accepts connections <Code>localhost:19988</Code> and only accepts connections
from the extension. There&apos;s no remote server, no account, no from the extension. There&apos;s no remote server, no account, no
telemetry. telemetry.
</Paragraph> </P>
<List> <List>
<Li> <Li>
@@ -981,8 +430,7 @@ playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock>
</List> </List>
</Section> </Section>
</article>
</div> </EditorialPage>
</div>
); );
} }