format
This commit is contained in:
+301
-312
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { startTransition } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
import { startTransition } from 'react'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { HydratedRouter } from 'react-router/dom'
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(document, <HydratedRouter />);
|
||||
});
|
||||
hydrateRoot(document, <HydratedRouter />)
|
||||
})
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { isbot } from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
AppLoadContext,
|
||||
EntryContext,
|
||||
LoaderFunctionArgs,
|
||||
} from "react-router";
|
||||
import { isRouteErrorResponse, ServerRouter } from "react-router";
|
||||
import { notifyError } from "./lib/errors";
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { createReadableStreamFromReadable } from '@react-router/node'
|
||||
import { isbot } from 'isbot'
|
||||
import { renderToPipeableStream } from 'react-dom/server'
|
||||
import type { ActionFunctionArgs, AppLoadContext, EntryContext, LoaderFunctionArgs } from 'react-router'
|
||||
import { isRouteErrorResponse, ServerRouter } from 'react-router'
|
||||
import { notifyError } from './lib/errors'
|
||||
|
||||
export const streamTimeout = 60 * 20 * 1_000;
|
||||
export const streamTimeout = 60 * 20 * 1_000
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
@@ -21,32 +16,22 @@ export default function handleRequest(
|
||||
// This is ignored so we can keep it in the template for visibility. Feel
|
||||
// free to delete this parameter in your app if you're not using it!
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
loadContext: AppLoadContext
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
return isbot(request.headers.get("user-agent") || "")
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
reactRouterContext
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
reactRouterContext
|
||||
);
|
||||
return isbot(request.headers.get('user-agent') || '')
|
||||
? handleBotRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
|
||||
: handleBrowserRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
reactRouterContext: EntryContext
|
||||
reactRouterContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let shellRendered = false
|
||||
let timeoutId: NodeJS.Timeout
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter
|
||||
context={reactRouterContext}
|
||||
@@ -55,51 +40,51 @@ function handleBotRequest(
|
||||
/>,
|
||||
{
|
||||
onAllReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(timeoutId)
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
}),
|
||||
)
|
||||
|
||||
pipe(body);
|
||||
pipe(body)
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
responseStatusCode = 500
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
timeoutId = setTimeout(abort, streamTimeout);
|
||||
});
|
||||
timeoutId = setTimeout(abort, streamTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
reactRouterContext: EntryContext
|
||||
reactRouterContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let shellRendered = false
|
||||
let timeoutId: NodeJS.Timeout
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter
|
||||
context={reactRouterContext}
|
||||
@@ -108,56 +93,53 @@ function handleBrowserRequest(
|
||||
/>,
|
||||
{
|
||||
onShellReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(timeoutId)
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
}),
|
||||
)
|
||||
|
||||
pipe(body);
|
||||
pipe(body)
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
responseStatusCode = 500
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
timeoutId = setTimeout(abort, streamTimeout);
|
||||
});
|
||||
timeoutId = setTimeout(abort, streamTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
export function handleError(
|
||||
error: any,
|
||||
{ request, params, context }: LoaderFunctionArgs | ActionFunctionArgs
|
||||
) {
|
||||
export function handleError(error: any, { request, params, context }: LoaderFunctionArgs | ActionFunctionArgs) {
|
||||
// https://github.com/remix-run/remix/discussions/8933
|
||||
if (request.signal.aborted || isRouteErrorResponse(error)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (error?.["status"] === 404) {
|
||||
return;
|
||||
if (error?.['status'] === 404) {
|
||||
return
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
notifyError(error, "unhandled remix error");
|
||||
notifyError(error, 'unhandled remix error')
|
||||
} else {
|
||||
console.error("error", error);
|
||||
console.error('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,34 +1,34 @@
|
||||
import { captureException, flush, init } from "sentries";
|
||||
import { captureException, flush, init } from 'sentries'
|
||||
|
||||
init({
|
||||
dsn: "https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168",
|
||||
dsn: 'https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168',
|
||||
integrations: [],
|
||||
tracesSampleRate: 0.01,
|
||||
profilesSampleRate: 0.01,
|
||||
beforeSend(event) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return null;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return null
|
||||
}
|
||||
if (process.env.BYTECODE_RUN) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
if (event?.["name"] === "AbortError") {
|
||||
return null;
|
||||
if (event?.['name'] === 'AbortError') {
|
||||
return null
|
||||
}
|
||||
|
||||
return event;
|
||||
return event
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export async function notifyError(error: any, msg?: string) {
|
||||
console.error(msg, error);
|
||||
captureException(error, { extra: { msg } });
|
||||
await flush(1000);
|
||||
console.error(msg, error)
|
||||
captureException(error, { extra: { msg } })
|
||||
await flush(1000)
|
||||
}
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export const sleep = (ms: number): Promise<void> => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
+30
-45
@@ -1,40 +1,26 @@
|
||||
import "website/src/styles/globals.css";
|
||||
import type { LinksFunction } from "react-router";
|
||||
import { Route } from "./+types/root";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
import 'website/src/styles/globals.css'
|
||||
import type { LinksFunction } from 'react-router'
|
||||
import { Route } from './+types/root'
|
||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "icon", type: "image/png", href: "/favicon-32.png", sizes: "32x32" },
|
||||
{ rel: "icon", type: "image/png", href: "/favicon-16.png", sizes: "16x16" },
|
||||
];
|
||||
{ rel: 'icon', type: 'image/png', href: '/favicon-32.png', sizes: '32x32' },
|
||||
{ rel: 'icon', type: 'image/png', href: '/favicon-16.png', sizes: '16x16' },
|
||||
]
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin=""
|
||||
/>
|
||||
<meta charSet='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<link rel='preconnect' href='https://fonts.googleapis.com' />
|
||||
<link rel='preconnect' href='https://fonts.gstatic.com' crossOrigin='' />
|
||||
{/* Inter from rsms (same source as next/font) for weight fidelity */}
|
||||
<link href='https://rsms.me/inter/inter.css' rel='stylesheet' />
|
||||
<link
|
||||
href="https://rsms.me/inter/inter.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap"
|
||||
rel="stylesheet"
|
||||
href='https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap'
|
||||
rel='stylesheet'
|
||||
/>
|
||||
<Meta />
|
||||
<Links />
|
||||
@@ -45,40 +31,39 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (isRouteErrorResponse(error)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
<div className='flex flex-col items-center justify-center min-h-screen p-8 text-center'>
|
||||
<h1 className='text-4xl font-bold mb-4'>
|
||||
{error.status} {error.statusText}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">{error.data}</p>
|
||||
<p className='text-lg text-gray-600'>{error.data}</p>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
} else if (error instanceof Error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-4">Error</h1>
|
||||
<p className="text-lg text-gray-600 mb-6">{error.message}</p>
|
||||
<p className="text-sm text-gray-500 mb-2">The stack trace is:</p>
|
||||
<pre className="bg-gray-100 p-4 rounded-lg text-xs text-left overflow-auto max-w-full border">
|
||||
<div className='flex flex-col items-center justify-center min-h-screen p-8 text-center max-w-4xl mx-auto'>
|
||||
<h1 className='text-4xl font-bold mb-4'>Error</h1>
|
||||
<p className='text-lg text-gray-600 mb-6'>{error.message}</p>
|
||||
<p className='text-sm text-gray-500 mb-2'>The stack trace is:</p>
|
||||
<pre className='bg-gray-100 p-4 rounded-lg text-xs text-left overflow-auto max-w-full border'>
|
||||
{error.stack}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<h1 className="text-4xl font-bold text-center">Unknown Error</h1>
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<h1 className='text-4xl font-bold text-center'>Unknown Error</h1>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RouteConfig } from "@react-router/dev/routes";
|
||||
import { flatRoutes } from "@react-router/fs-routes";
|
||||
import type { RouteConfig } from '@react-router/dev/routes'
|
||||
import { flatRoutes } from '@react-router/fs-routes'
|
||||
|
||||
export default flatRoutes() satisfies RouteConfig;
|
||||
export default flatRoutes() satisfies RouteConfig
|
||||
|
||||
+176
-249
@@ -4,8 +4,8 @@
|
||||
* Styles from globals.css (editorial tokens) and editorial-prism.css.
|
||||
*/
|
||||
|
||||
import type { MetaFunction } from "react-router";
|
||||
import dedent from "string-dedent";
|
||||
import type { MetaFunction } from 'react-router'
|
||||
import dedent from 'string-dedent'
|
||||
import {
|
||||
EditorialPage,
|
||||
P,
|
||||
@@ -19,150 +19,134 @@ import {
|
||||
OL,
|
||||
Li,
|
||||
PixelatedImage,
|
||||
} from "website/src/components/markdown";
|
||||
import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png";
|
||||
} from 'website/src/components/markdown'
|
||||
import placeholderScreenshot from '../assets/placeholders/placeholder-screenshot@2x.png'
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
const title = "Playwriter - Control your Chrome with Playwright API";
|
||||
const title = 'Playwriter - Control your Chrome with Playwright API'
|
||||
const description =
|
||||
"Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat.";
|
||||
const image = "https://playwriter.dev/og-image.png";
|
||||
'Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat.'
|
||||
const image = 'https://playwriter.dev/og-image.png'
|
||||
return [
|
||||
{ title },
|
||||
{ name: "description", content: description },
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:image", content: image },
|
||||
{ property: "og:image:width", content: "1200" },
|
||||
{ property: "og:image:height", content: "630" },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: "https://playwriter.dev" },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:title", content: title },
|
||||
{ name: "twitter:description", content: description },
|
||||
{ name: "twitter:image", content: image },
|
||||
];
|
||||
};
|
||||
{ name: 'description', content: description },
|
||||
{ property: 'og:title', content: title },
|
||||
{ property: 'og:description', content: description },
|
||||
{ property: 'og:image', content: image },
|
||||
{ property: 'og:image:width', content: '1200' },
|
||||
{ property: 'og:image:height', content: '630' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: 'https://playwriter.dev' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: title },
|
||||
{ name: 'twitter:description', content: description },
|
||||
{ name: 'twitter:image', content: image },
|
||||
]
|
||||
}
|
||||
|
||||
const tocItems = [
|
||||
{ label: "Getting started", href: "#getting-started" },
|
||||
{ label: "How it works", href: "#how-it-works" },
|
||||
{ label: "Collaboration", href: "#collaboration" },
|
||||
{ label: "Snapshots", href: "#snapshots" },
|
||||
{ label: "Visual labels", href: "#visual-labels" },
|
||||
{ label: "Sessions", href: "#sessions" },
|
||||
{ label: "Debugger & editor", href: "#debugger-and-editor" },
|
||||
{ label: "Network interception", href: "#network-interception" },
|
||||
{ label: "Screen recording", href: "#screen-recording" },
|
||||
{ label: "Comparison", href: "#comparison" },
|
||||
{ label: "Remote access", href: "#remote-access" },
|
||||
{ label: "Security", href: "#security" },
|
||||
];
|
||||
{ label: 'Getting started', href: '#getting-started' },
|
||||
{ label: 'How it works', href: '#how-it-works' },
|
||||
{ label: 'Collaboration', href: '#collaboration' },
|
||||
{ label: 'Snapshots', href: '#snapshots' },
|
||||
{ label: 'Visual labels', href: '#visual-labels' },
|
||||
{ label: 'Sessions', href: '#sessions' },
|
||||
{ label: 'Debugger & editor', href: '#debugger-and-editor' },
|
||||
{ label: 'Network interception', href: '#network-interception' },
|
||||
{ label: 'Screen recording', href: '#screen-recording' },
|
||||
{ label: 'Comparison', href: '#comparison' },
|
||||
{ label: 'Remote access', href: '#remote-access' },
|
||||
{ label: 'Security', href: '#security' },
|
||||
]
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<EditorialPage toc={tocItems} logo="playwriter">
|
||||
|
||||
<EditorialPage toc={tocItems} logo='playwriter'>
|
||||
<P>
|
||||
You want your agent to control the browser. <strong>Your actual
|
||||
Chrome</strong> {" \u2014 "} with logins, extensions, and cookies already
|
||||
there. Not a headless instance that gets blocked by every captcha
|
||||
and bot detector.{" "}
|
||||
<A href="https://github.com/remorses/playwriter">Star on GitHub</A>.
|
||||
You want your agent to control the browser. <strong>Your actual Chrome</strong> {' \u2014 '} with logins,
|
||||
extensions, and cookies already there. Not a headless instance that gets blocked by every captcha and bot
|
||||
detector. <A href='https://github.com/remorses/playwriter'>Star on GitHub</A>.
|
||||
</P>
|
||||
|
||||
<div className="bleed" style={{ display: "flex", justifyContent: "center" }}>
|
||||
<div className='bleed' style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<PixelatedImage
|
||||
src="/screenshot@2x.png"
|
||||
src='/screenshot@2x.png'
|
||||
placeholder={placeholderScreenshot}
|
||||
alt="Playwriter controlling Chrome with accessibility labels overlay"
|
||||
alt='Playwriter controlling Chrome with accessibility labels overlay'
|
||||
width={1280}
|
||||
height={800}
|
||||
style={{ display: "block", maxWidth: "100%", height: "auto" }}
|
||||
style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
<Caption>
|
||||
Your existing Chrome session. Extensions, logins, cookies {" \u2014 "} all there.
|
||||
</Caption>
|
||||
<Caption>Your existing Chrome session. Extensions, logins, cookies {' \u2014 '} all there.</Caption>
|
||||
|
||||
<P>
|
||||
Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents
|
||||
a fixed set of tools. New Chrome means no logins, no extensions,
|
||||
instant bot detection, and double the memory. Fixed tools mean the
|
||||
agent can{"'"}t profile performance, can{"'"}t set breakpoints,
|
||||
can{"'"}t intercept network requests {" \u2014 "} it can only do what someone
|
||||
decided to expose.
|
||||
Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents a fixed set of tools. New Chrome
|
||||
means no logins, no extensions, instant bot detection, and double the memory. Fixed tools mean the agent can
|
||||
{"'"}t profile performance, can{"'"}t set breakpoints, can{"'"}t intercept network requests {' \u2014 '} it can
|
||||
only do what someone decided to expose.
|
||||
</P>
|
||||
|
||||
<P>
|
||||
Playwriter gives agents the <strong>full Playwright API</strong> through
|
||||
a single <Code>execute</Code> tool. One tool, any Playwright code,
|
||||
no wrappers. Low context usage because there{"'"}s no schema bloat
|
||||
from dozens of tool definitions. And it runs in your existing browser,
|
||||
so <strong>nothing extra gets spawned</strong>.
|
||||
Playwriter gives agents the <strong>full Playwright API</strong> through a single <Code>execute</Code> tool. One
|
||||
tool, any Playwright code, no wrappers. Low context usage because there{"'"}s no schema bloat from dozens of
|
||||
tool definitions. And it runs in your existing browser, so <strong>nothing extra gets spawned</strong>.
|
||||
</P>
|
||||
|
||||
<Section id="getting-started" title="Getting started">
|
||||
|
||||
<Section id='getting-started' title='Getting started'>
|
||||
<P>
|
||||
<strong>Four steps</strong> and your agent is browsing.
|
||||
</P>
|
||||
|
||||
<OL>
|
||||
<Li>
|
||||
Install the{" "}
|
||||
<A href="https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe">Chrome extension</A>
|
||||
Install the{' '}
|
||||
<A href='https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe'>
|
||||
Chrome extension
|
||||
</A>
|
||||
</Li>
|
||||
<Li>Click the extension icon on a tab {" \u2014 "} it turns green</Li>
|
||||
<Li>Click the extension icon on a tab {' \u2014 '} it turns green</Li>
|
||||
<Li>Install the CLI:</Li>
|
||||
</OL>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
npm i -g playwriter
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Then install the <strong>skill</strong> {" \u2014 "} it teaches your agent how to use
|
||||
Playwriter: which selectors to use, how to avoid timeouts, how to
|
||||
read snapshots, and all available utilities.
|
||||
Then install the <strong>skill</strong> {' \u2014 '} it teaches your agent how to use Playwriter: which
|
||||
selectors to use, how to avoid timeouts, how to read snapshots, and all available utilities.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
npx -y skills add remorses/playwriter
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
The extension connects your browser to a <strong>local WebSocket relay</strong> on{" "}
|
||||
<Code>localhost:19988</Code>. The CLI sends Playwright
|
||||
code through the relay. No remote servers, no accounts, nothing
|
||||
leaves your machine.
|
||||
The extension connects your browser to a <strong>local WebSocket relay</strong> on{' '}
|
||||
<Code>localhost:19988</Code>. The CLI sends Playwright code through the relay. No remote servers, no accounts,
|
||||
nothing leaves your machine.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
playwriter session new # new sandbox, outputs id (e.g. 1)
|
||||
playwriter -s 1 -e "await page.goto('https://example.com')"
|
||||
playwriter -s 1 -e "console.log(await snapshot({ page }))"
|
||||
playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"
|
||||
`}</CodeBlock>
|
||||
|
||||
<Caption>
|
||||
Extension icon green = connected. Gray = not attached to this tab.
|
||||
</Caption>
|
||||
|
||||
<Caption>Extension icon green = connected. Gray = not attached to this tab.</Caption>
|
||||
</Section>
|
||||
|
||||
<Section id="how-it-works" title="How it works">
|
||||
|
||||
<Section id='how-it-works' title='How it works'>
|
||||
<P>
|
||||
Click the extension icon on a tab {" \u2014 "} it attaches via{" "}
|
||||
<Code>chrome.debugger</Code> and opens a WebSocket to a
|
||||
local relay. Your agent (CLI, MCP, or a Playwright script) connects
|
||||
to the same relay. <strong>CDP commands flow through</strong>; the extension
|
||||
forwards them to Chrome and sends responses back. No Chrome restart,
|
||||
no flags, no special setup.
|
||||
Click the extension icon on a tab {' \u2014 '} it attaches via <Code>chrome.debugger</Code> and opens a
|
||||
WebSocket to a local relay. Your agent (CLI, MCP, or a Playwright script) connects to the same relay.{' '}
|
||||
<strong>CDP commands flow through</strong>; the extension forwards them to Chrome and sends responses back. No
|
||||
Chrome restart, no flags, no special setup.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash" lineHeight="1.3">{dedent`
|
||||
<CodeBlock lang='bash' lineHeight='1.3'>{dedent`
|
||||
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
|
||||
│ BROWSER │ │ LOCALHOST │ │ CLIENT │
|
||||
│ │ │ │ │ │
|
||||
@@ -180,42 +164,35 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
The relay <strong>multiplexes sessions</strong>, so multiple agents
|
||||
or CLI instances can work with the same browser at the same time.
|
||||
The relay <strong>multiplexes sessions</strong>, so multiple agents or CLI instances can work with the same
|
||||
browser at the same time.
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="collaboration" title="Collaboration">
|
||||
|
||||
<Section id='collaboration' title='Collaboration'>
|
||||
<P>
|
||||
Because the agent works in <strong>your browser</strong>, you can
|
||||
collaborate. You see everything it does in real time. When it hits
|
||||
a captcha, <strong>you solve it</strong>. When a consent wall
|
||||
appears, you click through it. When the agent gets stuck, you
|
||||
disable the extension on that tab, fix things manually, re-enable
|
||||
Because the agent works in <strong>your browser</strong>, you can collaborate. You see everything it does in
|
||||
real time. When it hits a captcha, <strong>you solve it</strong>. When a consent wall appears, you click
|
||||
through it. When the agent gets stuck, you disable the extension on that tab, fix things manually, re-enable
|
||||
it, and the agent picks up where it left off.
|
||||
</P>
|
||||
|
||||
<P>
|
||||
You{"'"}re not watching a remote screen or reading logs after the
|
||||
fact. You{"'"}re <strong>sharing a browser</strong> {" \u2014 "} the
|
||||
agent does the repetitive work, you step in when it needs a human.
|
||||
You{"'"}re not watching a remote screen or reading logs after the fact. You{"'"}re{' '}
|
||||
<strong>sharing a browser</strong> {' \u2014 '} the agent does the repetitive work, you step in when it needs
|
||||
a human.
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="snapshots" title="Accessibility snapshots">
|
||||
|
||||
<Section id='snapshots' title='Accessibility snapshots'>
|
||||
<P>
|
||||
Your agent needs to <strong>see the page</strong> before it can act.
|
||||
Accessibility snapshots return every interactive element as text,
|
||||
with Playwright locators attached. <strong>5{"\u2013"}20KB instead of
|
||||
100KB+</strong> for a screenshot {" \u2014 "} cheaper, faster, and the
|
||||
Your agent needs to <strong>see the page</strong> before it can act. Accessibility snapshots return every
|
||||
interactive element as text, with Playwright locators attached.{' '}
|
||||
<strong>5{'\u2013'}20KB instead of 100KB+</strong> for a screenshot {' \u2014 '} cheaper, faster, and the
|
||||
agent can parse them without vision.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
playwriter -s 1 -e "await snapshot({ page })"
|
||||
|
||||
# Output:
|
||||
@@ -227,13 +204,12 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Each line ends with a <strong>locator</strong> you can pass directly to{" "}
|
||||
<Code>page.locator()</Code>. Subsequent calls return a
|
||||
<strong> diff</strong>, so you only see what changed. Use{" "}
|
||||
<Code>search</Code> to filter large pages.
|
||||
Each line ends with a <strong>locator</strong> you can pass directly to <Code>page.locator()</Code>.
|
||||
Subsequent calls return a<strong> diff</strong>, so you only see what changed. Use <Code>search</Code> to
|
||||
filter large pages.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
# Search for specific elements
|
||||
playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
|
||||
|
||||
@@ -242,25 +218,19 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Use snapshots as the <strong>primary way to read pages</strong>. Only
|
||||
reach for screenshots when spatial layout matters {" \u2014 "} grids,
|
||||
dashboards, maps.
|
||||
Use snapshots as the <strong>primary way to read pages</strong>. Only reach for screenshots when spatial
|
||||
layout matters {' \u2014 '} grids, dashboards, maps.
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="visual-labels" title="Visual labels">
|
||||
|
||||
<Section id='visual-labels' title='Visual labels'>
|
||||
<P>
|
||||
When the agent needs to understand <strong>where things are on
|
||||
screen</strong>,{" "}
|
||||
<Code>screenshotWithAccessibilityLabels</Code> overlays{" "}
|
||||
<strong>Vimium-style labels</strong> on every interactive element.
|
||||
The agent sees the screenshot, reads the labels, and clicks by
|
||||
reference.
|
||||
When the agent needs to understand <strong>where things are on screen</strong>,{' '}
|
||||
<Code>screenshotWithAccessibilityLabels</Code> overlays <strong>Vimium-style labels</strong> on every
|
||||
interactive element. The agent sees the screenshot, reads the labels, and clicks by reference.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })"
|
||||
# Returns screenshot + accessibility snapshot with aria-ref selectors
|
||||
|
||||
@@ -268,29 +238,22 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Labels are <strong>color-coded by element type</strong>: yellow for links, orange for
|
||||
buttons, coral for inputs, pink for checkboxes, peach for sliders,
|
||||
salmon for menus, amber for tabs. The ref system is shared with{" "}
|
||||
<Code>snapshot()</Code>, so you can switch between text
|
||||
and visual modes freely.
|
||||
Labels are <strong>color-coded by element type</strong>: yellow for links, orange for buttons, coral for
|
||||
inputs, pink for checkboxes, peach for sliders, salmon for menus, amber for tabs. The ref system is shared
|
||||
with <Code>snapshot()</Code>, so you can switch between text and visual modes freely.
|
||||
</P>
|
||||
|
||||
<Caption>
|
||||
Vimium-style labels. Screenshot + snapshot in one call.
|
||||
</Caption>
|
||||
|
||||
<Caption>Vimium-style labels. Screenshot + snapshot in one call.</Caption>
|
||||
</Section>
|
||||
|
||||
<Section id="sessions" title="Sessions">
|
||||
|
||||
<Section id='sessions' title='Sessions'>
|
||||
<P>
|
||||
Run <strong>multiple agents at once</strong> without them stepping on
|
||||
each other. Each session is an isolated sandbox with its own{" "}
|
||||
<Code>state</Code> object. Variables, pages, and listeners
|
||||
persist between calls. Browser tabs are shared, but state is not.
|
||||
Run <strong>multiple agents at once</strong> without them stepping on each other. Each session is an isolated
|
||||
sandbox with its own <Code>state</Code> object. Variables, pages, and listeners persist between calls. Browser
|
||||
tabs are shared, but state is not.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
playwriter session new # => 1
|
||||
playwriter session new # => 2
|
||||
playwriter session list # shows sessions + state keys
|
||||
@@ -303,30 +266,26 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Create your own page to <strong>avoid interference</strong> from other agents. Reuse
|
||||
an existing <Code>about:blank</Code> tab or create a
|
||||
fresh one, and store it in <Code>state</Code>.
|
||||
Create your own page to <strong>avoid interference</strong> from other agents. Reuse an existing{' '}
|
||||
<Code>about:blank</Code> tab or create a fresh one, and store it in <Code>state</Code>.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')"
|
||||
|
||||
# All subsequent calls use state.myPage
|
||||
playwriter -s 1 -e "console.log(await state.myPage.title())"
|
||||
`}</CodeBlock>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="debugger-and-editor" title="Debugger & editor">
|
||||
|
||||
<Section id='debugger-and-editor' title='Debugger & editor'>
|
||||
<P>
|
||||
Things no other browser MCP can do. <strong>Set breakpoints</strong>,
|
||||
step through code, inspect variables at runtime. <strong>Live-edit
|
||||
page scripts and CSS</strong> without reloading. Full Chrome DevTools
|
||||
Protocol access, not a watered-down subset.
|
||||
Things no other browser MCP can do. <strong>Set breakpoints</strong>, step through code, inspect variables at
|
||||
runtime. <strong>Live-edit page scripts and CSS</strong> without reloading. Full Chrome DevTools Protocol
|
||||
access, not a watered-down subset.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
# Set breakpoints and debug
|
||||
playwriter -s 1 -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()"
|
||||
playwriter -s 1 -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))"
|
||||
@@ -338,28 +297,21 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for
|
||||
toggling debug flags, patching broken code, or testing quick fixes
|
||||
without touching source files. The editor also supports{" "}
|
||||
Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for toggling debug flags,
|
||||
patching broken code, or testing quick fixes without touching source files. The editor also supports{' '}
|
||||
<Code>grep</Code> across all loaded scripts.
|
||||
</P>
|
||||
|
||||
<Caption>
|
||||
Breakpoints, stepping, variable inspection {" \u2014 "} from the CLI.
|
||||
</Caption>
|
||||
|
||||
<Caption>Breakpoints, stepping, variable inspection {' \u2014 '} from the CLI.</Caption>
|
||||
</Section>
|
||||
|
||||
<Section id="network-interception" title="Network interception">
|
||||
|
||||
<Section id='network-interception' title='Network interception'>
|
||||
<P>
|
||||
Let the agent <strong>watch network traffic</strong> to
|
||||
reverse-engineer APIs, scrape data behind JavaScript rendering,
|
||||
or debug failing requests. Captured data lives in{" "}
|
||||
<Code>state</Code> and persists across calls.
|
||||
Let the agent <strong>watch network traffic</strong> to reverse-engineer APIs, scrape data behind JavaScript
|
||||
rendering, or debug failing requests. Captured data lives in <Code>state</Code> and persists across calls.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
# Start intercepting
|
||||
playwriter -s 1 -e "state.responses = []; page.on('response', async res => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }); } catch {} } })"
|
||||
|
||||
@@ -372,24 +324,20 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
<strong>Faster than scraping the DOM.</strong> The agent captures the
|
||||
real API calls, inspects their schemas, and replays them with
|
||||
different parameters. Works for pagination, authenticated endpoints,
|
||||
and anything behind client-side rendering.
|
||||
<strong>Faster than scraping the DOM.</strong> The agent captures the real API calls, inspects their schemas,
|
||||
and replays them with different parameters. Works for pagination, authenticated endpoints, and anything behind
|
||||
client-side rendering.
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="screen-recording" title="Screen recording">
|
||||
|
||||
<Section id='screen-recording' title='Screen recording'>
|
||||
<P>
|
||||
Have the agent <strong>record what it{"'"}s doing</strong> as MP4
|
||||
video. The recording uses <Code>chrome.tabCapture</Code> and
|
||||
runs in the extension context, so it <strong>survives page
|
||||
navigation</strong>.
|
||||
Have the agent <strong>record what it{"'"}s doing</strong> as MP4 video. The recording uses{' '}
|
||||
<Code>chrome.tabCapture</Code> and runs in the extension context, so it{' '}
|
||||
<strong>survives page navigation</strong>.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
# Start recording
|
||||
playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })"
|
||||
|
||||
@@ -403,75 +351,64 @@ export default function IndexPage() {
|
||||
|
||||
<P>
|
||||
Unlike <Code>getDisplayMedia</Code>, this approach
|
||||
<strong> persists across navigations</strong> because the extension holds the{" "}
|
||||
<Code>MediaRecorder</Code>, not the page. You can also
|
||||
check recording status with <Code>isRecording</Code> or
|
||||
cancel without saving with <Code>cancelRecording</Code>.
|
||||
<strong> persists across navigations</strong> because the extension holds the <Code>MediaRecorder</Code>, not
|
||||
the page. You can also check recording status with <Code>isRecording</Code> or cancel without saving with{' '}
|
||||
<Code>cancelRecording</Code>.
|
||||
</P>
|
||||
|
||||
<Caption>
|
||||
Native tab capture. 30{"\u2013"}60fps. Survives navigation.
|
||||
</Caption>
|
||||
|
||||
<Caption>Native tab capture. 30{'\u2013'}60fps. Survives navigation.</Caption>
|
||||
</Section>
|
||||
|
||||
<Section id="comparison" title="Comparison">
|
||||
|
||||
<P>
|
||||
Why use this over the alternatives.
|
||||
</P>
|
||||
<Section id='comparison' title='Comparison'>
|
||||
<P>Why use this over the alternatives.</P>
|
||||
|
||||
<ComparisonTable
|
||||
title="vs Playwright MCP"
|
||||
headers={["", "Playwright MCP", "Playwriter"]}
|
||||
title='vs Playwright MCP'
|
||||
headers={['', 'Playwright MCP', 'Playwriter']}
|
||||
rows={[
|
||||
["Browser", "Spawns new Chrome", "Uses your Chrome"],
|
||||
["Extensions", "None", "Your existing ones"],
|
||||
["Login state", "Fresh", "Already logged in"],
|
||||
["Bot detection", "Always detected", "Can bypass"],
|
||||
["Collaboration", "Separate window", "Same browser as user"],
|
||||
['Browser', 'Spawns new Chrome', 'Uses your Chrome'],
|
||||
['Extensions', 'None', 'Your existing ones'],
|
||||
['Login state', 'Fresh', 'Already logged in'],
|
||||
['Bot detection', 'Always detected', 'Can bypass'],
|
||||
['Collaboration', 'Separate window', 'Same browser as user'],
|
||||
]}
|
||||
/>
|
||||
|
||||
<ComparisonTable
|
||||
title="vs BrowserMCP"
|
||||
headers={["", "BrowserMCP", "Playwriter"]}
|
||||
title='vs BrowserMCP'
|
||||
headers={['', 'BrowserMCP', 'Playwriter']}
|
||||
rows={[
|
||||
["Tools", "12+ dedicated tools", "1 execute tool"],
|
||||
["API", "Limited actions", "Full Playwright"],
|
||||
["Context usage", "High (tool schemas)", "Low"],
|
||||
["LLM knowledge", "Must learn tools", "Already knows Playwright"],
|
||||
['Tools', '12+ dedicated tools', '1 execute tool'],
|
||||
['API', 'Limited actions', 'Full Playwright'],
|
||||
['Context usage', 'High (tool schemas)', 'Low'],
|
||||
['LLM knowledge', 'Must learn tools', 'Already knows Playwright'],
|
||||
]}
|
||||
/>
|
||||
|
||||
<ComparisonTable
|
||||
title="vs Claude Browser Extension"
|
||||
headers={["", "Claude Extension", "Playwriter"]}
|
||||
title='vs Claude Browser Extension'
|
||||
headers={['', 'Claude Extension', 'Playwriter']}
|
||||
rows={[
|
||||
["Agent support", "Claude only", "Any MCP client"],
|
||||
["Windows WSL", "No", "Yes"],
|
||||
["Context method", "Screenshots (100KB+)", "A11y snapshots (5\u201320KB)"],
|
||||
["Playwright API", "No", "Full"],
|
||||
["Debugger", "No", "Yes"],
|
||||
["Live code editing", "No", "Yes"],
|
||||
["Network interception", "Limited", "Full"],
|
||||
["Raw CDP access", "No", "Yes"],
|
||||
['Agent support', 'Claude only', 'Any MCP client'],
|
||||
['Windows WSL', 'No', 'Yes'],
|
||||
['Context method', 'Screenshots (100KB+)', 'A11y snapshots (5\u201320KB)'],
|
||||
['Playwright API', 'No', 'Full'],
|
||||
['Debugger', 'No', 'Yes'],
|
||||
['Live code editing', 'No', 'Yes'],
|
||||
['Network interception', 'Limited', 'Full'],
|
||||
['Raw CDP access', 'No', 'Yes'],
|
||||
]}
|
||||
/>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="remote-access" title="Remote access">
|
||||
|
||||
<Section id='remote-access' title='Remote access'>
|
||||
<P>
|
||||
Control Chrome on a <strong>remote machine</strong> {" \u2014 "} a headless
|
||||
Mac mini, a cloud VM, a devcontainer. A{" "}
|
||||
<A href="https://traforo.dev">traforo</A>{" "}
|
||||
tunnel exposes the relay through Cloudflare. <strong>No VPN, no
|
||||
firewall rules, no port forwarding.</strong>
|
||||
Control Chrome on a <strong>remote machine</strong> {' \u2014 '} a headless Mac mini, a cloud VM, a
|
||||
devcontainer. A <A href='https://traforo.dev'>traforo</A> tunnel exposes the relay through Cloudflare.{' '}
|
||||
<strong>No VPN, no firewall rules, no port forwarding.</strong>
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
<CodeBlock lang='bash'>{dedent`
|
||||
# On the host machine — start relay with tunnel
|
||||
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
|
||||
|
||||
@@ -482,46 +419,36 @@ export default function IndexPage() {
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Also works on a <strong>LAN without tunnels</strong> {" \u2014 "} just set{" "}
|
||||
<Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP
|
||||
too {" \u2014 "} set <Code>PLAYWRITER_HOST</Code> and{" "}
|
||||
<Code>PLAYWRITER_TOKEN</Code> in your MCP client env config.
|
||||
Use cases: headless Mac mini, remote user support,
|
||||
multi-machine automation, dev from a VM or devcontainer.
|
||||
Also works on a <strong>LAN without tunnels</strong> {' \u2014 '} just set{' '}
|
||||
<Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP too {' \u2014 '} set <Code>PLAYWRITER_HOST</Code> and{' '}
|
||||
<Code>PLAYWRITER_TOKEN</Code> in your MCP client env config. Use cases: headless Mac mini, remote user
|
||||
support, multi-machine automation, dev from a VM or devcontainer.
|
||||
</P>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="security" title="Security">
|
||||
|
||||
<Section id='security' title='Security'>
|
||||
<P>
|
||||
Everything runs <strong>on your machine</strong>. The relay binds
|
||||
to <Code>localhost:19988</Code> and only accepts connections
|
||||
from the extension. No remote server, no account, no telemetry.
|
||||
Everything runs <strong>on your machine</strong>. The relay binds to <Code>localhost:19988</Code> and only
|
||||
accepts connections from the extension. No remote server, no account, no telemetry.
|
||||
</P>
|
||||
|
||||
<List>
|
||||
<Li>
|
||||
<strong>Local only</strong> {" \u2014 "} WebSocket server binds to
|
||||
localhost. Nothing leaves your machine.
|
||||
<strong>Local only</strong> {' \u2014 '} WebSocket server binds to localhost. Nothing leaves your machine.
|
||||
</Li>
|
||||
<Li>
|
||||
<strong>Origin validation</strong> {" \u2014 "} only the Playwriter
|
||||
extension origin is accepted. Browsers cannot spoof the Origin
|
||||
header, so malicious websites cannot connect.
|
||||
<strong>Origin validation</strong> {' \u2014 '} only the Playwriter extension origin is accepted. Browsers
|
||||
cannot spoof the Origin header, so malicious websites cannot connect.
|
||||
</Li>
|
||||
<Li>
|
||||
<strong>Explicit consent</strong> {" \u2014 "} only tabs where you
|
||||
clicked the extension icon are controlled. No background access.
|
||||
<strong>Explicit consent</strong> {' \u2014 '} only tabs where you clicked the extension icon are
|
||||
controlled. No background access.
|
||||
</Li>
|
||||
<Li>
|
||||
<strong>Visible automation</strong> {" \u2014 "} Chrome shows an
|
||||
automation banner on controlled tabs.
|
||||
<strong>Visible automation</strong> {' \u2014 '} Chrome shows an automation banner on controlled tabs.
|
||||
</Li>
|
||||
</List>
|
||||
|
||||
</Section>
|
||||
|
||||
</EditorialPage>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import React from "react";
|
||||
import { sleep } from "../lib/utils";
|
||||
import { Route } from "./+types/defer-example";
|
||||
import React from 'react'
|
||||
import { sleep } from '../lib/utils'
|
||||
import { Route } from './+types/defer-example'
|
||||
|
||||
async function getProjectLocation() {
|
||||
return Promise.resolve().then(() => sleep(1000).then(() => "hi"));
|
||||
return Promise.resolve().then(() => sleep(1000).then(() => 'hi'))
|
||||
}
|
||||
|
||||
export async function loader({}: Route.LoaderArgs) {
|
||||
return {
|
||||
project: getProjectLocation(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProjectRoute({ loaderData }: Route.ComponentProps) {
|
||||
const location = React.use(loaderData.project);
|
||||
const location = React.use(loaderData.project)
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Let's locate your project</h1>
|
||||
<p>Your project is at {location}.</p>
|
||||
</main>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { redirect } from 'react-router'
|
||||
|
||||
export const loader = () => {
|
||||
throw redirect('https://github.com/remorses/playwriter');
|
||||
};
|
||||
throw redirect('https://github.com/remorses/playwriter')
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -12,13 +12,12 @@
|
||||
*/
|
||||
|
||||
/* Base code style - override Prism defaults */
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
color: #1e293b;
|
||||
background: none;
|
||||
text-shadow: none;
|
||||
font-family: var(--font-code, "SF Mono", "SFMono-Regular", "Consolas",
|
||||
"Liberation Mono", Menlo, Courier, monospace);
|
||||
font-family: var(--font-code, 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo, Courier, monospace);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -163,8 +162,8 @@ pre[class*="language-"] {
|
||||
}
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
@variant dark {
|
||||
color: var(--prism-fg);
|
||||
}
|
||||
|
||||
@@ -25,11 +25,12 @@
|
||||
======================================================================== */
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono NF Mono";
|
||||
font-family: 'JetBrainsMono NF Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("https://raw.githubusercontent.com/ryanoasis/nerd-fonts/refs/tags/v3.3.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf") format("truetype");
|
||||
src: url('https://raw.githubusercontent.com/ryanoasis/nerd-fonts/refs/tags/v3.3.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf')
|
||||
format('truetype');
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
@@ -38,7 +39,9 @@
|
||||
|
||||
.editorial-page {
|
||||
font-optical-sizing: auto;
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
font-feature-settings:
|
||||
'liga' 1,
|
||||
'calt' 1;
|
||||
}
|
||||
|
||||
.editorial-page ::selection {
|
||||
@@ -119,7 +122,7 @@
|
||||
}
|
||||
|
||||
.inline-code::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1.26px 0;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
@@ -144,7 +147,7 @@
|
||||
======================================================================== */
|
||||
|
||||
.editorial-page::before {
|
||||
content: "";
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
z-index: 9;
|
||||
position: fixed;
|
||||
|
||||
+238
-243
@@ -1,294 +1,289 @@
|
||||
@import "tailwindcss";
|
||||
@import "./editorial.css";
|
||||
@import "./editorial-prism.css";
|
||||
@import 'tailwindcss';
|
||||
@import './editorial.css';
|
||||
@import './editorial-prism.css';
|
||||
@custom-variant dark (@media (prefers-color-scheme: dark));
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
/* ===== shadcn/ui tokens ===== */
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
/* ===== shadcn/ui tokens ===== */
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.637 0.237 25.331);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.871 0.006 286.286);
|
||||
--ring: oklch(0.871 0.006 286.286);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-primary: oklch(0.37 0.013 285.805);
|
||||
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.871 0.006 286.286);
|
||||
|
||||
/* ===== Editorial design tokens ===== */
|
||||
--font-primary:
|
||||
'Inter var', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
--font-secondary: 'Newsreader', Georgia, 'Times New Roman', serif;
|
||||
--font-code:
|
||||
'JetBrainsMono NF Mono', 'JetBrains Mono', 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo,
|
||||
Courier, monospace;
|
||||
|
||||
/* Bleed: code blocks & images extend beyond prose column.
|
||||
44px = 8px div padding-left + 36px line-number width */
|
||||
--bleed: 44px;
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-xxs: 0.5rem;
|
||||
--spacing-xs: 1rem;
|
||||
--spacing-sm: 1.5rem;
|
||||
--spacing-md: 2rem;
|
||||
--spacing-lg: 2.5rem;
|
||||
--spacing-xl: 3rem;
|
||||
--spacing-xxl: 3.5rem;
|
||||
|
||||
/* Timing */
|
||||
--duration-snappy: 220ms;
|
||||
--ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
||||
--duration-swift: 800ms;
|
||||
--ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
--duration-smooth: 300ms;
|
||||
--ease-smooth: cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
/* Colors — near-black, not pure black */
|
||||
--text-primary: rgb(17, 17, 17);
|
||||
--text-secondary: rgba(0, 0, 0, 0.4);
|
||||
--text-tertiary: rgba(0, 0, 0, 0.25);
|
||||
--text-muted: rgba(0, 0, 0, 0.5);
|
||||
--text-hover: rgba(0, 0, 0, 0.7);
|
||||
--bg: #fff;
|
||||
--page-border: #e3e3e3;
|
||||
--divider: rgb(242, 242, 242);
|
||||
--code-bg: rgba(0, 0, 0, 0.02);
|
||||
--code-line-nr: rgba(0, 0, 0, 0.3);
|
||||
--selection-bg: rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Button / back button */
|
||||
--btn-bg: #fff;
|
||||
--btn-shadow:
|
||||
rgba(0, 0, 0, 0.08) 0px 2px 8px, rgba(0, 0, 0, 0.04) 0px 4px 16px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
|
||||
|
||||
/* Link accent color (renamed from --accent to avoid shadcn conflict) */
|
||||
--link-accent: #0969da;
|
||||
|
||||
/* P3 accent colors (renamed from --primary/--secondary to avoid shadcn conflict) */
|
||||
--brand-primary: #3e9fff;
|
||||
--brand-secondary: #f09637;
|
||||
|
||||
/* Overlay / glass */
|
||||
--overlay-filter: blur(1rem);
|
||||
--overlay-bg: hsla(0, 0%, 100%, 0.8);
|
||||
--overlay-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04), 0 1.625rem 3.375rem rgba(0, 0, 0, 0.04), 0 1rem 2rem rgba(0, 0, 0, 0.03),
|
||||
0 0.625rem 1rem rgba(0, 0, 0, 0.024), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
|
||||
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016), 0 0 0.125rem rgba(0, 0, 0, 0.01);
|
||||
|
||||
/* Header fade gradient stops (white) */
|
||||
--fade-0: rgb(255, 255, 255);
|
||||
--fade-1: rgba(255, 255, 255, 0.737);
|
||||
--fade-2: rgba(255, 255, 255, 0.541);
|
||||
--fade-3: rgba(255, 255, 255, 0.382);
|
||||
--fade-4: rgba(255, 255, 255, 0.278);
|
||||
--fade-5: rgba(255, 255, 255, 0.194);
|
||||
--fade-6: rgba(255, 255, 255, 0.126);
|
||||
--fade-7: rgba(255, 255, 255, 0.075);
|
||||
--fade-8: rgba(255, 255, 255, 0.042);
|
||||
--fade-9: rgba(255, 255, 255, 0.021);
|
||||
--fade-10: rgba(255, 255, 255, 0.008);
|
||||
--fade-11: rgba(255, 255, 255, 0.002);
|
||||
--fade-12: rgba(255, 255, 255, 0);
|
||||
|
||||
/* Dark mode overrides — uses prefers-color-scheme media query
|
||||
via @custom-variant dark above */
|
||||
@variant dark {
|
||||
/* shadcn/ui dark */
|
||||
--background: oklch(0.21 0.006 285.885);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.637 0.237 25.331);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.871 0.006 286.286);
|
||||
--ring: oklch(0.871 0.006 286.286);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--border: oklch(0.274 0.006 286.033);
|
||||
--input: oklch(0.274 0.006 286.033);
|
||||
--ring: oklch(0.442 0.017 285.786);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: #000;
|
||||
--sidebar-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-primary: oklch(0.37 0.013 285.805);
|
||||
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.871 0.006 286.286);
|
||||
--sidebar-border: oklch(0.274 0.006 286.033);
|
||||
--sidebar-ring: oklch(0.442 0.017 285.786);
|
||||
|
||||
/* ===== Editorial design tokens ===== */
|
||||
--font-primary: "Inter var", "Inter", system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-secondary: "Newsreader", Georgia, "Times New Roman", serif;
|
||||
--font-code: "JetBrainsMono NF Mono", "JetBrains Mono", "SF Mono",
|
||||
"SFMono-Regular", "Consolas", "Liberation Mono", Menlo, Courier, monospace;
|
||||
/* Editorial dark */
|
||||
--text-primary: rgba(255, 255, 255, 0.9);
|
||||
--text-secondary: rgba(255, 255, 255, 0.45);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.25);
|
||||
--text-muted: rgba(255, 255, 255, 0.35);
|
||||
--text-hover: rgba(255, 255, 255, 0.7);
|
||||
--bg: rgb(17, 17, 17);
|
||||
--page-border: rgba(255, 255, 255, 0.1);
|
||||
--divider: rgba(255, 255, 255, 0.06);
|
||||
--code-bg: rgba(255, 255, 255, 0.05);
|
||||
--code-line-nr: rgba(255, 255, 255, 0.3);
|
||||
--selection-bg: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* Bleed: code blocks & images extend beyond prose column.
|
||||
44px = 8px div padding-left + 36px line-number width */
|
||||
--bleed: 44px;
|
||||
--btn-bg: rgb(30, 30, 30);
|
||||
--btn-shadow:
|
||||
rgba(255, 255, 255, 0.05) 0px 2px 8px, rgba(255, 255, 255, 0.02) 0px 4px 16px,
|
||||
rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-xxs: 0.5rem;
|
||||
--spacing-xs: 1rem;
|
||||
--spacing-sm: 1.5rem;
|
||||
--spacing-md: 2rem;
|
||||
--spacing-lg: 2.5rem;
|
||||
--spacing-xl: 3rem;
|
||||
--spacing-xxl: 3.5rem;
|
||||
--link-accent: #58a6ff;
|
||||
--brand-primary: #60a5fa;
|
||||
--brand-secondary: #f5a623;
|
||||
|
||||
/* Timing */
|
||||
--duration-snappy: 220ms;
|
||||
--ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
||||
--duration-swift: 800ms;
|
||||
--ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
--duration-smooth: 300ms;
|
||||
--ease-smooth: cubic-bezier(0.19, 1, 0.22, 1);
|
||||
--overlay-bg: hsla(0, 0%, 7%, 0.8);
|
||||
--overlay-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06), 0 1.625rem 3.375rem rgba(0, 0, 0, 0.3), 0 1rem 2rem rgba(0, 0, 0, 0.2),
|
||||
0 0.625rem 1rem rgba(0, 0, 0, 0.15), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
|
||||
0 0.125rem 0.25rem rgba(0, 0, 0, 0.08), 0 0 0.125rem rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Colors — near-black, not pure black */
|
||||
--text-primary: rgb(17, 17, 17);
|
||||
--text-secondary: rgba(0, 0, 0, 0.4);
|
||||
--text-tertiary: rgba(0, 0, 0, 0.25);
|
||||
--text-muted: rgba(0, 0, 0, 0.5);
|
||||
--text-hover: rgba(0, 0, 0, 0.7);
|
||||
--bg: #fff;
|
||||
--page-border: #e3e3e3;
|
||||
--divider: rgb(242, 242, 242);
|
||||
--code-bg: rgba(0, 0, 0, 0.02);
|
||||
--code-line-nr: rgba(0, 0, 0, 0.3);
|
||||
--selection-bg: rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Button / back button */
|
||||
--btn-bg: #fff;
|
||||
--btn-shadow: rgba(0, 0, 0, 0.08) 0px 2px 8px,
|
||||
rgba(0, 0, 0, 0.04) 0px 4px 16px,
|
||||
rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
|
||||
|
||||
/* Link accent color (renamed from --accent to avoid shadcn conflict) */
|
||||
--link-accent: #0969da;
|
||||
|
||||
/* P3 accent colors (renamed from --primary/--secondary to avoid shadcn conflict) */
|
||||
--brand-primary: #3e9fff;
|
||||
--brand-secondary: #f09637;
|
||||
|
||||
/* Overlay / glass */
|
||||
--overlay-filter: blur(1rem);
|
||||
--overlay-bg: hsla(0, 0%, 100%, 0.8);
|
||||
--overlay-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04),
|
||||
0 1.625rem 3.375rem rgba(0, 0, 0, 0.04),
|
||||
0 1rem 2rem rgba(0, 0, 0, 0.03),
|
||||
0 0.625rem 1rem rgba(0, 0, 0, 0.024),
|
||||
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
|
||||
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016),
|
||||
0 0 0.125rem rgba(0, 0, 0, 0.01);
|
||||
|
||||
/* Header fade gradient stops (white) */
|
||||
--fade-0: rgb(255, 255, 255);
|
||||
--fade-1: rgba(255, 255, 255, 0.737);
|
||||
--fade-2: rgba(255, 255, 255, 0.541);
|
||||
--fade-3: rgba(255, 255, 255, 0.382);
|
||||
--fade-4: rgba(255, 255, 255, 0.278);
|
||||
--fade-5: rgba(255, 255, 255, 0.194);
|
||||
--fade-6: rgba(255, 255, 255, 0.126);
|
||||
--fade-7: rgba(255, 255, 255, 0.075);
|
||||
--fade-8: rgba(255, 255, 255, 0.042);
|
||||
--fade-9: rgba(255, 255, 255, 0.021);
|
||||
--fade-10: rgba(255, 255, 255, 0.008);
|
||||
--fade-11: rgba(255, 255, 255, 0.002);
|
||||
--fade-12: rgba(255, 255, 255, 0);
|
||||
|
||||
/* Dark mode overrides — uses prefers-color-scheme media query
|
||||
via @custom-variant dark above */
|
||||
@variant dark {
|
||||
/* shadcn/ui dark */
|
||||
--background: oklch(0.21 0.006 285.885);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.637 0.237 25.331);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.274 0.006 286.033);
|
||||
--input: oklch(0.274 0.006 286.033);
|
||||
--ring: oklch(0.442 0.017 285.786);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: #000;
|
||||
--sidebar-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-primary: oklch(0.37 0.013 285.805);
|
||||
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
|
||||
--sidebar-border: oklch(0.274 0.006 286.033);
|
||||
--sidebar-ring: oklch(0.442 0.017 285.786);
|
||||
|
||||
/* Editorial dark */
|
||||
--text-primary: rgba(255, 255, 255, 0.9);
|
||||
--text-secondary: rgba(255, 255, 255, 0.45);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.25);
|
||||
--text-muted: rgba(255, 255, 255, 0.35);
|
||||
--text-hover: rgba(255, 255, 255, 0.7);
|
||||
--bg: rgb(17, 17, 17);
|
||||
--page-border: rgba(255, 255, 255, 0.1);
|
||||
--divider: rgba(255, 255, 255, 0.06);
|
||||
--code-bg: rgba(255, 255, 255, 0.05);
|
||||
--code-line-nr: rgba(255, 255, 255, 0.3);
|
||||
--selection-bg: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--btn-bg: rgb(30, 30, 30);
|
||||
--btn-shadow: rgba(255, 255, 255, 0.05) 0px 2px 8px,
|
||||
rgba(255, 255, 255, 0.02) 0px 4px 16px,
|
||||
rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
|
||||
|
||||
--link-accent: #58a6ff;
|
||||
--brand-primary: #60a5fa;
|
||||
--brand-secondary: #f5a623;
|
||||
|
||||
--overlay-bg: hsla(0, 0%, 7%, 0.8);
|
||||
--overlay-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 1.625rem 3.375rem rgba(0, 0, 0, 0.3),
|
||||
0 1rem 2rem rgba(0, 0, 0, 0.2),
|
||||
0 0.625rem 1rem rgba(0, 0, 0, 0.15),
|
||||
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
|
||||
0 0.125rem 0.25rem rgba(0, 0, 0, 0.08),
|
||||
0 0 0.125rem rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Header fade gradient stops (dark) */
|
||||
--fade-0: rgb(17, 17, 17);
|
||||
--fade-1: rgba(17, 17, 17, 0.737);
|
||||
--fade-2: rgba(17, 17, 17, 0.541);
|
||||
--fade-3: rgba(17, 17, 17, 0.382);
|
||||
--fade-4: rgba(17, 17, 17, 0.278);
|
||||
--fade-5: rgba(17, 17, 17, 0.194);
|
||||
--fade-6: rgba(17, 17, 17, 0.126);
|
||||
--fade-7: rgba(17, 17, 17, 0.075);
|
||||
--fade-8: rgba(17, 17, 17, 0.042);
|
||||
--fade-9: rgba(17, 17, 17, 0.021);
|
||||
--fade-10: rgba(17, 17, 17, 0.008);
|
||||
--fade-11: rgba(17, 17, 17, 0.002);
|
||||
--fade-12: rgba(17, 17, 17, 0);
|
||||
}
|
||||
/* Header fade gradient stops (dark) */
|
||||
--fade-0: rgb(17, 17, 17);
|
||||
--fade-1: rgba(17, 17, 17, 0.737);
|
||||
--fade-2: rgba(17, 17, 17, 0.541);
|
||||
--fade-3: rgba(17, 17, 17, 0.382);
|
||||
--fade-4: rgba(17, 17, 17, 0.278);
|
||||
--fade-5: rgba(17, 17, 17, 0.194);
|
||||
--fade-6: rgba(17, 17, 17, 0.126);
|
||||
--fade-7: rgba(17, 17, 17, 0.075);
|
||||
--fade-8: rgba(17, 17, 17, 0.042);
|
||||
--fade-9: rgba(17, 17, 17, 0.021);
|
||||
--fade-10: rgba(17, 17, 17, 0.008);
|
||||
--fade-11: rgba(17, 17, 17, 0.002);
|
||||
--fade-12: rgba(17, 17, 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* P3 wide-gamut accent colors */
|
||||
@supports (color: color(display-p3 1 1 1)) {
|
||||
:root {
|
||||
--brand-primary: color(display-p3 0.243137 0.623529 1 / 1);
|
||||
--brand-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1);
|
||||
}
|
||||
:root {
|
||||
--brand-primary: color(display-p3 0.243137 0.623529 1 / 1);
|
||||
--brand-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles: shadcn-inspired */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@variant dark {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user