| description | Read and write files in the Lit Playground (lit.dev/playground) via Chrome DevTools MCP. Use when the user asks to edit, update, read, or interact with a Lit Playground URL. |
|---|
You are interacting with the Lit Playground at lit.dev/playground. The playground
is built from nested web components with shadow DOMs. All interactions go through
mcp__chrome-devtools__evaluate_script.
Context: These playgrounds contain Lit web components — a library built on
native browser APIs like Custom Elements, Shadow DOM, and HTML templates. The code
you'll encounter is deeply tied to the browser: reactive properties trigger re-renders
into shadow roots, CSS is scoped per-component via shadow encapsulation, events
compose across shadow boundaries, and the preview iframe runs a live compiled build.
Many concepts (shadow DOM selection, slotting, CSS custom properties piercing shadow
boundaries, adoptedStyleSheets, etc.) have no server-side or Node.js equivalent —
they only exist in the browser. Always reason about the code in terms of how the
browser will interpret it, not how a bundler or server would.
IMPORTANT: ALWAYS use Chrome DevTools MCP. NEVER use Playwright MCP.
If Chrome DevTools MCP tools (mcp__chrome-devtools__*) are not available, the user
needs to install and configure it. Walk them through the following steps:
Add the following to ~/.claude/.mcp.json (global) or .claude/.mcp.json (project):
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--autoConnect"
]
}
}
}The --autoConnect flag is important — it tells the MCP server to automatically
attach to the first available Chrome tab, so you don't have to manually select a
page every time.
Chrome DevTools MCP connects to Chrome via the Chrome DevTools Protocol (CDP). Chrome must be running with remote debugging enabled:
Option A — Launch Chrome with the flag:
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
# Or for Chrome Beta:
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --remote-debugging-port=9222Option B — Use chrome://inspect (recommended):
- Open Chrome and navigate to
chrome://inspect - Click "Configure..." next to "Discover network targets"
- Ensure
localhost:9222is in the list - Under "Remote Target" you should see your open tabs listed — this confirms remote debugging is active
Option C — Enable via Chrome DevTools settings:
- Open Chrome DevTools (
Cmd+Option+Ion macOS) - Click the gear icon (Settings)
- Under "Experiments", check "Protocol Monitor"
- This enables the DevTools protocol which the MCP server connects to
Add to your ~/.claude/settings.json:
{
"permissions": {
"allow": [
"mcp__chrome-devtools__*"
]
}
}After restarting Claude Code, the agent should be able to call
mcp__chrome-devtools__list_pages to see open browser tabs. If this works,
the setup is complete.
document
└─ <playground-project> ← data model (files, build, config)
└─ <playground-tab-bar> ← file tabs (shadow → playground-internal-tab)
└─ <playground-file-editor> ← editor pane (shadow → playground-code-editor)
└─ playground-code-editor ← (shadow → .cm-editor) CodeMirror 6
└─ <playground-preview> ← output (shadow → iframe)
If not already on the playground page, navigate first:
// Use mcp__chrome-devtools__navigate_page with type="url"() => {
const project = document.querySelector('playground-project');
return project._files.map(f => ({ name: f.name, hidden: !!f.hidden }));
}// Read by filename:
() => {
const project = document.querySelector('playground-project');
const file = project._files.find(f => f.name === '$ARGUMENTS');
return file ? file.content : 'File not found';
}If $ARGUMENTS is empty or the user didn't specify a file, list files first (Step 2),
then read the one(s) you need.
To read ALL files at once:
() => {
const project = document.querySelector('playground-project');
return Object.fromEntries(
project._files.map(f => [f.name, f.content])
);
}Use project.editFile(fileObj, newContent). This updates the data model, triggers
TypeScript compilation, and auto-refreshes the preview iframe.
() => {
const project = document.querySelector('playground-project');
const file = project._files.find(f => f.name === 'TARGET_FILENAME');
if (!file) return 'File not found';
project.editFile(file, `NEW_CONTENT_HERE`);
return 'OK: updated ' + file.name;
}This is the preferred edit method. It works for ANY file regardless of which tab is active, and it handles the rebuild + preview refresh automatically.
After ANY edit, you MUST verify your changes worked by checking BOTH the source file AND the live preview. Do not assume edits succeeded — always confirm.
() => {
const project = document.querySelector('playground-project');
const file = project._files.find(f => f.name === 'TARGET_FILENAME');
return file?.content?.substring(0, 200);
}First, call take_snapshot to get fresh uids (the preview iframe rebuilds
after edits, so old 2_* uids may be stale). Then use a 2_* uid to inspect
the live DOM:
// args: ["2_*"] (a fresh uid from the latest snapshot)
(anyIframeEl) => {
const doc = anyIframeEl.ownerDocument;
// Check the rendered HTML
const el = doc.querySelector('TARGET-ELEMENT');
return {
exists: !!el,
shadowHTML: el?.shadowRoot?.innerHTML?.substring(0, 300),
// Read any reactive properties you changed
someProp: el?.someProp,
};
}() => {
const project = document.querySelector('playground-project');
return {
buildState: project._build?._state, // "done" = success
diagnostics: project.diagnostics, // [] = no errors
};
}Always do steps 5a + 5b after edits. The source might be correct but the preview could have runtime errors. Checking the live preview catches issues that source-level verification alone would miss.
The preview runs in a cross-origin iframe (playground.lit.dev), so
contentDocument is null from the parent page. You CANNOT access it via
iframe.contentDocument. Instead, use the two approaches below.
After take_snapshot, elements from the parent page have uids like 1_26,
while elements inside the preview iframe have uids like 2_3, 2_5, etc.
The 2_* prefix means "inside the iframe".
The simplest way to interact with preview elements. Just use the uid directly:
mcp__chrome-devtools__click uid="2_5" ← clicks the button inside the iframe
Pass ANY 2_* uid as an arg, then use el.ownerDocument to get the iframe's
full document. From there you can reach anything.
Read a Lit element's live properties:
// args: ["2_5"] (any uid from inside the iframe)
(anyIframeEl) => {
const doc = anyIframeEl.ownerDocument;
const el = doc.querySelector('my-element');
return {
condition: el.condition, // reactive property
shadowHTML: el.shadowRoot.innerHTML, // rendered output
};
}Set a Lit reactive property (triggers re-render):
// args: ["2_5"]
(anyIframeEl) => {
const el = anyIframeEl.ownerDocument.querySelector('my-element');
el.condition = true; // Lit re-renders automatically
return { condition: el.condition };
}Click a button inside a shadow root via JS:
// args: ["2_5"]
(anyIframeEl) => {
const doc = anyIframeEl.ownerDocument;
const el = doc.querySelector('my-element');
el.shadowRoot.querySelector('button').click();
return { condition: el.condition };
}Inspect the full iframe DOM tree:
// args: ["2_5"]
(anyIframeEl) => {
const doc = anyIframeEl.ownerDocument;
// List all elements (including custom elements and their shadow DOMs)
const customs = [...doc.querySelectorAll('*')]
.filter(el => el.tagName.includes('-'))
.map(el => ({
tag: el.tagName.toLowerCase(),
hasShadow: !!el.shadowRoot,
attrs: [...el.attributes].map(a => `${a.name}="${a.value}"`),
properties: Object.keys(el.constructor.properties || {}),
}));
return { bodyHTML: doc.body.innerHTML, customElements: customs };
}Read iframe console errors (by injecting a collector):
You can inject error collection into the preview by editing the HTML file:
() => {
const project = document.querySelector('playground-project');
const file = project._files.find(f => f.name === 'index.html');
const script = `<script>
window.__errors = [];
window.addEventListener('error', e => __errors.push(e.message));
window.addEventListener('unhandledrejection', e => __errors.push(e.reason?.message || String(e.reason)));
<\/script>`;
// Prepend the collector to the HTML
if (!file.content.includes('__errors')) {
project.editFile(file, file.content.replace('<head>', '<head>\n' + script));
}
return 'Error collector injected';
}Then read collected errors:
// args: ["2_5"]
(anyIframeEl) => {
return anyIframeEl.ownerDocument.defaultView.__errors || [];
}() => {
document.querySelector('playground-preview').reload();
return 'Preview reloaded';
}| Task | Method |
|---|---|
| Click a button | click(uid="2_5") — no JS needed |
| Read element property | evaluate_script with args:["2_*"], then el.ownerDocument.querySelector(...) |
| Set Lit property | el.someProperty = value — auto re-renders |
| Read shadow DOM | el.shadowRoot.innerHTML or el.shadowRoot.querySelector(...) |
| Full DOM dump | anyIframeEl.ownerDocument.body.innerHTML |
| Collect errors | Inject window.__errors via editFile on index.html |
| Reload preview | document.querySelector('playground-preview').reload() |
() => {
const tabBar = document.querySelector('playground-tab-bar');
const tab = tabBar.shadowRoot
.querySelector('playground-internal-tab-bar')
.querySelector('playground-internal-tab[data-filename="TARGET_FILENAME"]');
if (tab) { tab.click(); return 'Switched to TARGET_FILENAME'; }
return 'Tab not found';
}addFile(filename: string) — creates an empty file, triggers UI update.
() => {
const project = document.querySelector('playground-project');
if (!project.isValidNewFilename('new-file.ts')) return 'Invalid filename';
project.addFile('new-file.ts');
// Optionally write content immediately:
const file = project._files.find(f => f.name === 'new-file.ts');
project.editFile(file, 'export const foo = 42;');
return 'File added and populated';
}deleteFile(filename: string) — takes the filename string, not a file object.
() => {
const project = document.querySelector('playground-project');
project.deleteFile('TARGET_FILENAME');
return 'Deleted. Files: ' + project._files.map(f => f.name).join(', ');
}renameFile(oldName: string, newName: string) — takes two filename strings.
Validates the new name. Content is preserved.
() => {
const project = document.querySelector('playground-project');
project.renameFile('OLD_NAME', 'NEW_NAME');
return 'Renamed. Files: ' + project._files.map(f => f.name).join(', ');
}For surgical edits to the currently-visible file without replacing the entire content:
() => {
const fileEditor = document.querySelector('playground-file-editor');
const codeEditor = fileEditor.shadowRoot.querySelector('playground-code-editor');
const view = codeEditor._editorView;
const doc = view.state.doc.toString();
// Example: find-and-replace
const oldText = 'FIND_THIS';
const idx = doc.indexOf(oldText);
if (idx === -1) return 'Text not found';
view.dispatch({
changes: { from: idx, to: idx + oldText.length, insert: 'REPLACE_WITH' }
});
return 'Replaced at index ' + idx;
}| Task | API | Signature |
|---|---|---|
| List files | project._files |
.map(f => f.name) |
| Read file | project._files.find(...) |
.content (string) |
| Edit file | project.editFile(fileObj, content) |
fileObj from _files, content string |
| Add file | project.addFile(name) |
filename string |
| Delete file | project.deleteFile(name) |
filename string |
| Rename file | project.renameFile(old, new) |
two filename strings |
| Valid name? | project.isValidNewFilename(name) |
returns boolean |
| Switch tab | click playground-internal-tab[data-filename="..."] |
inside tab-bar shadow root |
| CM6 view | codeEditor._editorView |
view.dispatch({changes: ...}) |
| Preview iframe | preview.shadowRoot.querySelector('iframe') |
cross-origin, use 2_* uids |
| Click in preview | click(uid="2_*") |
uid from snapshot |
| JS in preview | evaluate_script with args:["2_*"] |
el.ownerDocument for iframe doc |
| Read Lit state | el.propertyName |
reactive props auto-render |
| Read shadow DOM | el.shadowRoot.innerHTML |
or .querySelector(...) |
| Reload preview | preview.reload() |
— |
| Build status | project._build._state |
"done" when ready |
| Diagnostics | project.diagnostics |
TS errors |
IMPORTANT: Every time you read files or the page state, ALSO check for user selections. The user may have highlighted code in the editor or text in the preview iframe to indicate what they want you to focus on.
() => {
const fileEditor = document.querySelector('playground-file-editor');
const codeEditor = fileEditor.shadowRoot.querySelector('playground-code-editor');
const view = codeEditor._editorView;
const state = view.state;
const sel = state.selection.main;
if (sel.empty) return { hasSelection: false };
const selectedText = state.doc.sliceString(sel.from, sel.to);
const fromLine = state.doc.lineAt(sel.from).number;
const toLine = state.doc.lineAt(sel.to).number;
return {
hasSelection: true,
selectedText,
fromLine,
toLine,
fromOffset: sel.from,
toOffset: sel.to,
};
}The preview renders Lit components with shadow DOMs. Selections can span
across shadow root boundaries (sibling elements, nested elements, or both).
Standard document.getSelection() and window.getSelection() CANNOT see
into shadow roots — they return isCollapsed: true even when text is highlighted.
Use Selection.getComposedRanges(...shadowRoots) — the modern API that resolves
selections across shadow DOM boundaries — then walk the flat tree to extract text.
First call take_snapshot to get a fresh 2_* uid, then:
// args: ["2_*"] (any uid from inside the iframe)
(anyIframeEl) => {
const doc = anyIframeEl.ownerDocument;
const win = doc.defaultView;
const sel = win.getSelection();
if (!sel || sel.rangeCount === 0) return { hasSelection: false };
// Collect ALL shadow roots (including nested ones)
function collectShadowRoots(root, roots = []) {
const els = root.querySelectorAll('*');
for (const el of els) {
if (el.shadowRoot) {
roots.push(el.shadowRoot);
collectShadowRoots(el.shadowRoot, roots);
}
}
return roots;
}
const shadowRoots = collectShadowRoots(doc);
// Use getComposedRanges to get selection boundaries that cross shadow DOMs
if (!sel.getComposedRanges) {
// Fallback for browsers without getComposedRanges
return { hasSelection: false, reason: 'getComposedRanges not supported' };
}
const ranges = sel.getComposedRanges(...shadowRoots);
const range = ranges[0];
if (!range || range.collapsed) return { hasSelection: false };
// Walk the flat tree (entering shadow roots for both siblings and nested)
function flatTreeWalk(node, callback) {
callback(node);
if (node.shadowRoot) {
for (const child of node.shadowRoot.childNodes) flatTreeWalk(child, callback);
}
for (const child of node.childNodes) flatTreeWalk(child, callback);
}
// Collect all text nodes in flat tree order
const allTextNodes = [];
flatTreeWalk(doc.body, (node) => {
if (node.nodeType === 3) allTextNodes.push(node);
});
// Resolve start/end to text nodes
let startNode, startOff, endNode, endOff;
if (range.startContainer.nodeType === 3) {
startNode = range.startContainer;
startOff = range.startOffset;
} else {
const child = range.startContainer.childNodes[range.startOffset];
if (child) flatTreeWalk(child, (n) => {
if (!startNode && n.nodeType === 3) { startNode = n; startOff = 0; }
});
}
if (range.endContainer.nodeType === 3) {
endNode = range.endContainer;
endOff = range.endOffset;
} else {
const child = range.endContainer.childNodes[range.endOffset - 1];
if (child) flatTreeWalk(child, (n) => {
if (n.nodeType === 3) { endNode = n; endOff = n.textContent.length; }
});
}
// Collect text between start and end nodes
let collecting = false;
let selectedText = '';
for (const tn of allTextNodes) {
if (tn === startNode) {
collecting = true;
if (tn === endNode) {
selectedText += tn.textContent.substring(startOff, endOff);
break;
}
selectedText += tn.textContent.substring(startOff);
continue;
}
if (tn === endNode) {
selectedText += tn.textContent.substring(0, endOff);
break;
}
if (collecting) selectedText += tn.textContent;
}
return {
hasSelection: true,
selectedText: selectedText.trim(),
};
}Key details:
collectShadowRootsrecursively finds ALL shadow roots, including nested ones (e.g.<outer-el>shadow →<inner-el>shadow)flatTreeWalktraverses the composed/flat tree, entering shadow roots for both sibling and nested custom elementsgetComposedRanges(...shadowRoots)takes all shadow roots as arguments so it can resolve selection boundaries that land inside any of them- The text extraction walks all text nodes in flat-tree order between the resolved start and end positions
- Always run both selection checks when you first read the playground state
- Always check after the user says "this", "here", "that part", or references highlighted/selected content
- The editor selection tells you which code the user is focused on
- The preview selection tells you which rendered output the user is pointing at
- Use both to infer the user's intent when their message is ambiguous
Prefer using the selection detection functions above over screenshots to understand what the user is referring to. Only take a screenshot as a fallback if the selection functions don't reveal what the user is talking about, or for these specific situations:
- After edits that affect styling (CSS changes, layout changes, visual appearance)
- When the user mentions anything visual (colors, spacing, alignment, fonts, sizing)
- After initial page load to confirm the playground rendered correctly
- Before and after changes to show the user what changed
- When debugging visual issues — a screenshot is worth a thousand DOM inspections
- When selection detection returns nothing but the user says "this" or "here"
Screenshots capture the full viewport including the code editor (left) and the live preview (right), giving you visual context for both the source and the output.
For styling-focused work, prefer this verification flow:
- Make the edit via
editFile() - Take a screenshot to see the visual result
- If the user is asking about specific styles, also inspect computed styles:
// args: ["2_*"]
(anyIframeEl) => {
const doc = anyIframeEl.ownerDocument;
const el = doc.querySelector('TARGET-ELEMENT');
// Check rendered styles inside shadow DOM
const styledEl = el.shadowRoot.querySelector('.TARGET_CLASS_OR_TAG');
const styles = anyIframeEl.ownerDocument.defaultView.getComputedStyle(styledEl);
return {
color: styles.color,
background: styles.backgroundColor,
fontSize: styles.fontSize,
padding: styles.padding,
margin: styles.margin,
display: styles.display,
};
}- Read before editing — always read the file first so you have context for your changes.
- Check user selections first — every time you read files or the page, also check for editor and preview selections to understand the user's focus. Prefer selection detection over screenshots for understanding user intent.
- Use
editFile()over CodeMirror dispatch — it works on any file and handles the full rebuild pipeline. - Verify after editing — read the file back and/or check the preview to confirm your changes took effect.
- Screenshot as fallback or for visual changes — screenshot after style/layout edits, or when selection detection doesn't reveal what the user is referencing.
- Content is a string — when calling
editFile, pass the full new content as a string. Escape backticks and${if using template literals. - Hidden files exist —
package.jsonis hidden from tabs but still editable viaeditFile. - API gotcha —
editFiletakes a file object, butdeleteFileandrenameFiletake filename strings. - Preview is cross-origin — never use
iframe.contentDocument. Always use2_*uids from the snapshot as args toevaluate_script, thenel.ownerDocumentto get the iframe's document. - Take a snapshot first — before interacting with the preview, call
take_snapshotto get fresh2_*uids for elements inside the iframe.