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:
@@ -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
@@ -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<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;
|
||||
}
|
||||
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,526 +33,35 @@ const tocItems = [
|
||||
{ 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() {
|
||||
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 />
|
||||
<EditorialPage toc={tocItems} logo="playwriter">
|
||||
|
||||
<div
|
||||
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>
|
||||
<P>
|
||||
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.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<ChartPlaceholder height={300} label="demo" />
|
||||
<Caption>
|
||||
Your existing Chrome session. Extensions, logins, cookies — all there.
|
||||
</Caption>
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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{" "}
|
||||
<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.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<Section id="getting-started" title="Getting started">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
Three steps. Extension, icon click, then you're automating.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`# 1. Install the Chrome extension
|
||||
# https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe
|
||||
@@ -622,12 +72,12 @@ export default function LivelinePage() {
|
||||
npm i -g playwriter
|
||||
playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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
|
||||
leaves your machine.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`playwriter session new # new sandbox, outputs id (e.g. 1)
|
||||
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">
|
||||
|
||||
<Paragraph>
|
||||
The extension uses <InlineCode>chrome.debugger</InlineCode> to
|
||||
<P>
|
||||
The extension uses <Code>chrome.debugger</Code> 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.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`
|
||||
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
|
||||
<CodeBlock lang="bash">{`┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────┐
|
||||
│ BROWSER │ │ LOCALHOST │ │ CLIENT │
|
||||
│ │ │ │ │ │
|
||||
│ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │
|
||||
│ │ Extension │<───────┬───> :19988 │ │ │ CLI / MCP │ │
|
||||
│ │ Extension │<-----------> :19988 │ │ │ CLI / MCP │ │
|
||||
│ └───────┬───────┘ │ WS │ │ │ └───────────┘ │
|
||||
│ │ │ │ /extension │ │ │ │
|
||||
│ chrome.debugger │ │ │ │ │ v │
|
||||
│ v │ │ v │ │ ┌────────────┐ │
|
||||
│ ┌───────────────┐ │ │ /cdp/:id <───────────────>│ │ execute │ │
|
||||
│ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └────────────┘ │
|
||||
│ v │ │ v │ │ ┌───────────┐ │
|
||||
│ ┌───────────────┐ │ │ /cdp/:id <--------------->│ │ execute │ │
|
||||
│ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └───────────┘ │
|
||||
│ │ Tab 2 (green) │ │ │ │ │
|
||||
│ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │
|
||||
└─────────────────────┘ (extension not clicked) └─────────────────┘`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
No Chrome restart required. No <InlineCode>--remote-debugging-port</InlineCode>{" "}
|
||||
<P>
|
||||
No Chrome restart required. No <Code>--remote-debugging-port</Code>{" "}
|
||||
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.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="snapshots" title="Accessibility snapshots">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
The core feedback loop is <strong>observe → act → observe</strong>.
|
||||
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.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<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 "Blog" role=link[name="Blog"]`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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{" "}
|
||||
<InlineCode>search</InlineCode> to filter large pages.
|
||||
</Paragraph>
|
||||
<Code>search</Code> to filter large pages.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`# Search for specific elements
|
||||
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
|
||||
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
|
||||
tokens. Use them as your primary debugging tool. Only reach for
|
||||
screenshots when spatial layout matters — grids, dashboards, maps.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<ChartPlaceholder height={200} label="snapshot" />
|
||||
<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">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
For pages where spatial layout matters,{" "}
|
||||
<InlineCode>screenshotWithAccessibilityLabels</InlineCode> overlays
|
||||
<Code>screenshotWithAccessibilityLabels</Code> overlays
|
||||
Vimium-style labels on every interactive element. Take a screenshot,
|
||||
read the labels, click by reference.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`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()"`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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{" "}
|
||||
<InlineCode>snapshot()</InlineCode>, so you can switch between text
|
||||
<Code>snapshot()</Code>, so you can switch between text
|
||||
and visual modes freely.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<ChartPlaceholder height={260} label="visual labels" />
|
||||
<Caption>
|
||||
@@ -752,12 +201,12 @@ playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"`}</CodeBlock>
|
||||
|
||||
<Section id="sessions" title="Sessions">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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
|
||||
different state. Browser tabs are shared.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`playwriter session new # => 1
|
||||
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
|
||||
playwriter -s 2 -e "console.log(state.users)" # undefined`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
Create your own page to avoid interference from other agents. Reuse
|
||||
an existing <InlineCode>about:blank</InlineCode> tab or create a
|
||||
fresh one, and store it in <InlineCode>state</InlineCode>.
|
||||
</Paragraph>
|
||||
an existing <Code>about:blank</Code> tab or create a
|
||||
fresh one, and store it in <Code>state</Code>.
|
||||
</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')"
|
||||
|
||||
@@ -784,11 +233,11 @@ playwriter -s 1 -e "console.log(await state.myPage.title())"`}</CodeBlock>
|
||||
|
||||
<Section id="debugger-and-editor" title="Debugger & editor">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
Full Chrome DevTools Protocol access. Set breakpoints, step through
|
||||
code, inspect variables at runtime. Live-edit page scripts and CSS
|
||||
without reloading.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<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()"
|
||||
@@ -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 "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
|
||||
toggling debug flags, patching broken code, or testing quick fixes
|
||||
without touching source files. The editor also supports{" "}
|
||||
<InlineCode>grep</InlineCode> across all loaded scripts.
|
||||
</Paragraph>
|
||||
<Code>grep</Code> across all loaded scripts.
|
||||
</P>
|
||||
|
||||
<ChartPlaceholder height={200} label="debugger" />
|
||||
<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">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
Intercept requests and responses to reverse-engineer APIs, scrape
|
||||
data, or debug network issues. Store captured data in{" "}
|
||||
<InlineCode>state</InlineCode> and analyze across calls.
|
||||
</Paragraph>
|
||||
<Code>state</Code> and analyze across calls.
|
||||
</P>
|
||||
|
||||
<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 {} } })"
|
||||
@@ -831,23 +280,23 @@ 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)"`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="screen-recording" title="Screen recording">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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
|
||||
as MP4.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`# Start recording
|
||||
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
|
||||
playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')"`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
Unlike <InlineCode>getDisplayMedia</InlineCode>, this approach
|
||||
<P>
|
||||
Unlike <Code>getDisplayMedia</Code>, this approach
|
||||
persists across navigations because the extension holds the{" "}
|
||||
<InlineCode>MediaRecorder</InlineCode>, not the page. You can also
|
||||
check recording status with <InlineCode>isRecording</InlineCode> or
|
||||
cancel without saving with <InlineCode>cancelRecording</InlineCode>.
|
||||
</Paragraph>
|
||||
<Code>MediaRecorder</Code>, not the page. You can also
|
||||
check recording status with <Code>isRecording</Code> or
|
||||
cancel without saving with <Code>cancelRecording</Code>.
|
||||
</P>
|
||||
|
||||
<ChartPlaceholder height={200} label="recording" />
|
||||
<Caption>
|
||||
@@ -876,9 +325,9 @@ playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page
|
||||
|
||||
<Section id="comparison" title="Comparison">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
How Playwriter compares to other browser automation approaches.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<PropsTable
|
||||
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">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{`# On the host machine
|
||||
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>
|
||||
playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
Also works on a LAN without tunnels — 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
|
||||
support, and multi-machine automation.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="security" title="Security">
|
||||
|
||||
<Paragraph>
|
||||
<P>
|
||||
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's no remote server, no account, no
|
||||
telemetry.
|
||||
</Paragraph>
|
||||
</P>
|
||||
|
||||
<List>
|
||||
<Li>
|
||||
@@ -981,8 +430,7 @@ playwriter -s 1 -e "await page.goto('https://example.com')"`}</CodeBlock>
|
||||
</List>
|
||||
|
||||
</Section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</EditorialPage>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user