Skip to content

Instantly share code, notes, and snippets.

@e111077
Created April 14, 2026 23:32
Show Gist options
  • Select an option

  • Save e111077/775a3830a06b2c9daead1f2fb5290f1d to your computer and use it in GitHub Desktop.

Select an option

Save e111077/775a3830a06b2c9daead1f2fb5290f1d to your computer and use it in GitHub Desktop.
Lit Playground Command Skill
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.

Lit Playground — Read & Write via Chrome DevTools MCP

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.

Prerequisites — Chrome DevTools MCP Setup

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:

1. Install the MCP server

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.

2. Enable Chrome Remote Debugging

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=9222

Option B — Use chrome://inspect (recommended):

  1. Open Chrome and navigate to chrome://inspect
  2. Click "Configure..." next to "Discover network targets"
  3. Ensure localhost:9222 is in the list
  4. Under "Remote Target" you should see your open tabs listed — this confirms remote debugging is active

Option C — Enable via Chrome DevTools settings:

  1. Open Chrome DevTools (Cmd+Option+I on macOS)
  2. Click the gear icon (Settings)
  3. Under "Experiments", check "Protocol Monitor"
  4. This enables the DevTools protocol which the MCP server connects to

3. Grant permissions in Claude Code

Add to your ~/.claude/settings.json:

{
  "permissions": {
    "allow": [
      "mcp__chrome-devtools__*"
    ]
  }
}

4. Verify the connection

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.

Architecture

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)

Step 1 — Navigate to the Playground

If not already on the playground page, navigate first:

// Use mcp__chrome-devtools__navigate_page with type="url"

Step 2 — List All Files

() => {
  const project = document.querySelector('playground-project');
  return project._files.map(f => ({ name: f.name, hidden: !!f.hidden }));
}

Step 3 — Read a File's Content

// 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])
  );
}

Step 4 — Edit a File (Primary Method)

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.

Step 5 — Verify Edits via Source AND Live Preview

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.

5a. Verify the source file was updated:

() => {
  const project = document.querySelector('playground-project');
  const file = project._files.find(f => f.name === 'TARGET_FILENAME');
  return file?.content?.substring(0, 200);
}

5b. Verify the live preview reflects your changes:

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,
  };
}

5c. Check for build errors:

() => {
  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.

Step 6 — Interact with & Debug the Preview Output

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.

Understanding iframe uid prefixes

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

Approach A: click / hover / fill with a 2_* uid (no JS needed)

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

Approach B: evaluate_script with a 2_* uid arg as an anchor

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 || [];
}

Force-reload the preview

() => {
  document.querySelector('playground-preview').reload();
  return 'Preview reloaded';
}

Quick Reference: Debugging the Preview

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()

Additional Operations

Switch the Active Tab

() => {
  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';
}

Add a New File

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';
}

Delete a File

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(', ');
}

Rename a File

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(', ');
}

CodeMirror 6 Direct Edit (active file only)

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;
}

Quick Reference — All APIs

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

Detect User Selections & Highlights

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.

Check CodeMirror editor selection (active file)

() => {
  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,
  };
}

Check preview iframe text selection

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:

  • collectShadowRoots recursively finds ALL shadow roots, including nested ones (e.g. <outer-el> shadow → <inner-el> shadow)
  • flatTreeWalk traverses the composed/flat tree, entering shadow roots for both sibling and nested custom elements
  • getComposedRanges(...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

When to check

  • 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

Taking Screenshots — Visual Verification

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:

  1. Make the edit via editFile()
  2. Take a screenshot to see the visual result
  3. 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,
  };
}

Workflow Reminders

  1. Read before editing — always read the file first so you have context for your changes.
  2. 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.
  3. Use editFile() over CodeMirror dispatch — it works on any file and handles the full rebuild pipeline.
  4. Verify after editing — read the file back and/or check the preview to confirm your changes took effect.
  5. Screenshot as fallback or for visual changes — screenshot after style/layout edits, or when selection detection doesn't reveal what the user is referencing.
  6. Content is a string — when calling editFile, pass the full new content as a string. Escape backticks and ${ if using template literals.
  7. Hidden files existpackage.json is hidden from tabs but still editable via editFile.
  8. API gotchaeditFile takes a file object, but deleteFile and renameFile take filename strings.
  9. Preview is cross-origin — never use iframe.contentDocument. Always use 2_* uids from the snapshot as args to evaluate_script, then el.ownerDocument to get the iframe's document.
  10. Take a snapshot first — before interacting with the preview, call take_snapshot to get fresh 2_* uids for elements inside the iframe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment