AV Creator
Create custom HTML visualizations and push them to Adom Viewer.
Terminology: "Widget" and "View" are synonyms. Both refer to an app/tab inside Adom Viewer. There is no separate category — every piece of content pushed to AV is a view/widget and gets the same treatment.
Rule #1: Always Show the User — EVERY TIME
After ANY action that produces a visual result in AV, you MUST screenshot it and show the user. This includes:
- Pushing new content to AV
- Updating or re-pushing existing content
- Publishing a wiki page (push the wiki page to AV as an iframe, then screenshot)
- Completing any workflow that changes what's visible in AV
- Editing a skill, view, or widget file (reload AV, then screenshot)
Never say "check your viewer", "it's published", "done", or "updated" without showing the result. The user should see the visual output inline in the conversation every single time. Capture it and present it. This is non-negotiable.
Rule #2: Real Views Only — No Fakes (keyword: real-view)
You MUST always use the REAL view/app — NEVER create a fake mockup, placeholder, or approximation. This is non-negotiable.
When showing content in AV — whether for a dropdown landing page, a wiki submission, a demo, or any other purpose — you MUST use the actual running view. This means:
- Landing pages: When a user selects a view from the AV dropdown, load the REAL app (the actual HTML/iframe), not a placeholder screen or SVG sketch. Use sample data if no user data is available (e.g., a sample GLB for 3dView, a sample .kicad_mod for Fp3dView).
- Wiki submissions: When capturing screenshots for wiki pages, capture the REAL view running with real data — not a generated mockup that looks similar.
- Demos and previews: Always instantiate the actual view. If you need data to populate it, use sample data or generate real data from a real source.
What "fake" means and why it's banned:
- A static SVG/HTML that looks like the 3D viewer but isn't the real Babylon.js viewer = FAKE
- A hand-crafted HTML page that approximates the fp3d pad view but doesn't use fp-to-3d.js = FAKE
- A screenshot description or explainer text where the real app should be = FAKE
- Generic placeholder screens with play-button icons and "Ask Claude to..." text = FAKE
The keyword real-view: If the user says "real-view", it is a reminder that you MUST use the actual view, not a mockup. But you should ALWAYS default to using real views even without the keyword — the keyword exists only as an explicit enforcement mechanism.
Why this matters: Views are the core feature of AV — they are real mini-apps. When you substitute a fake for a real view, you defeat the entire purpose of the platform.
Rule #3: Always Pass Attribution (viewId + skill + author)
Every tab in AV shows a tooltip on hover with attribution info. When pushing content — whether via curl, av_display, or av_display_file — you MUST always include viewId, skill, and author fields. This is non-negotiable.
viewId: The View ID (e.g.,ClaudeApi,SymView,FpView). Shown as "View: ClaudeApi" in tooltip. Also used for tab icon matching.skill: The skill name (e.g.,claude-api,av-creator,symbol-creator)author: Full name with handle (e.g.,John Lauer (@john))
MCP tool calls:
av_display_file(file_path, title, viewId="ClaudeApi", skill="claude-api", author="John Lauer (@john)")
av_display(content, title, viewId="SymView", skill="symbol-creator", author="John Lauer (@john)")
Tooltip shows (in order):
- View: ClaudeApi — the widget name, so users learn what's generating content
- Author: John Lauer (@john) — who made this
- Skill: claude-api — which skill created it
Why: Without these, the tooltip shows "Source: av_display_file" which is useless. The user wants to know what widget this is, who made it, and what skill created it — not the internal transport mechanism. The source field is auto-set by the server and only shown as a fallback when no viewId/skill/author are present.
Rule #4: Always Reload AV Yourself
After restarting the AV server or making any change that requires a browser refresh, you MUST reload AV yourself using av_reload. Never ask the user to refresh their browser — that's your job. This is non-negotiable.
Reload sequence after server restart:
- Call
av_reload(uses mgmt relay on port 8772, works even during server restart) - If
av_reloadreports 0 viewers connected, try once more after 3 seconds - Only if all attempts fail (viewer truly disconnected), tell the user: "I tried to reload AV for you automatically, but your viewer tab isn't connected to my servers right now. As a last resort, could you refresh the AV panel?"
Never just say "can you refresh AV?" without trying first. The user should never have to do busywork that you can handle.
Quick Reference
# 1. Push content to AV
curl -s http://127.0.0.1:8771/api/display -X POST \
-H "Content-Type: application/json" \
-d "{\"action\":\"display\",\"content\":{\"contentType\":\"html_interactive\",\"content\":\"$HTML\",\"title\":\"My Widget\",\"id\":\"$(uuidgen)\"}}"
# 2. Screenshot it (mgmt server)
curl -s http://127.0.0.1:8772/ -X POST \
-H "Content-Type: application/json" \
-d '{"action":"screenshot"}'
# Returns: { ok: true, filePath: "/home/adom/project/project-content/screenshots/mgmt-screenshot-*.png", data: "data:image/png;base64,..." }
# 3. Read the screenshot to show the user
# Use the Read tool on the filePath from step 2
Push API Shape
POST to http://127.0.0.1:8771/api/display:
{
"action": "display",
"content": {
"contentType": "html_interactive",
"content": "<html>...</html>",
"title": "Widget Title",
"id": "any-unique-string",
"source": "av_display",
"skill": "av-creator",
"author": "John Lauer"
}
}
The source, skill, and author fields are required. They appear in a tooltip when the user hovers over the tab for 1 second, showing attribution and origin.
author: Full name of the person whose skill created this contentskill: The skill name that triggered the creation (e.g.,av-creator,symbol-creator)source: The AV tool used (e.g.,av_display,av_display_file,av_3d_display)
For the id field, use uuidgen in bash or any unique string.
Content Types
| Type | When to use | Example |
|---|---|---|
html_interactive | Custom widgets with JS, charts, interactive content | Skills map, dashboards, data explorers |
html | Static HTML without scripts | Tables, formatted reports |
svg | Vector graphics | Diagrams, schematics, charts |
markdown | Text content | Documentation, notes, summaries |
image | Base64 data URI | Screenshots, photos |
Default to html_interactive for most visualizations — it supports <script> tags for interactivity.
html_interactive rendering: Content is rendered inline (injected into the page via innerHTML with scripts re-executed), NOT in a sandboxed iframe. This means:
- Scripts run in the same window context as AV — they have full access to
navigator.clipboard,document, etc. window.parent.postMessage()still works (sends to self when not in iframe, caught by AV's message listener).- Clipboard API works directly because the paste event's user activation is preserved (no iframe sandbox to block it).
- If
scrollable: trueis set on the content, it renders in apaddeddiv; otherwise in afullscreendiv.
Important: Always use the display action. All AV content MUST go through the display action so it appears as a tab in the tab bar. Never use fullscreen-only actions like show_jlcpcb_results or show_instapcb that bypass the tab system — those are legacy patterns being phased out. Tabs are essential for users to navigate between views, compare results, and maintain context. If you're building a new viewer or skill that pushes to AV, always use action: "display" with a contentType.
Tab Icons
Every AV tab gets an inline SVG icon. There are two layers:
Per-View Icons (for dropdown views)
Each view in VIEW_RENDERERS has its own unique icon in tabIconSvg(), keyed by _viewName. Views are like apps — every one MUST have a distinct icon. Examples:
| View | Icon |
|---|---|
| SymView | IC chip with pins |
| FpView | Top-down pad layout |
| SchView | Zigzag wire with nodes |
| 3dView | Wireframe cube with interior edges |
| Fp3dView | Extruded 3D pad in perspective |
| Basic3dView | Simple cube outline |
| DeskConduit | Laptop with upload arrow |
| ContConduit | Two boxes with bidirectional arrows |
| GChat | Chat bubble with text lines |
| InstrView | Oscilloscope with waveform |
| JlcSearch / MouserSearch / DkSearch | Shopping cart |
| InstaPCB | Circuit board with traces |
| MovieMaker | Film clapperboard |
Per-Skill Icons (for pushed content with skill field)
Pushed content can override the default contentType icon by matching the skill field. These are checked BEFORE contentType icons:
| Skill | Icon |
|---|---|
screenshot-paste | Clipboard with lines |
solder-jet-sizer | Nozzle with dots on pad |
To add a new skill icon, add a check in tabIconSvg() for item.skill === 'your-skill-name'.
Per-ContentType Icons (fallback for pushed content)
Pushed content (via av_display) gets icons based on contentType if no skill icon matched:
| Content Type | Icon |
|---|---|
html_interactive / html | </> code brackets |
3d | Wireframe cube with interior edges |
basic_3d | Simple cube outline |
symbol_3d | Rectangle + mini cube |
library_review | Three vertical panes |
svg | Frame with landscape |
image | Frame with photo |
markdown | M with chevron |
capture | Camera |
When creating a new view, you MUST add a unique icon in the tabIconSvg() function. Use 14x14px viewBox, stroke color ${c}, stroke-width="1", fill="none". The icon must be self-contained and visually distinct at small size.
Theme Tokens
Use these exact colors for all AV widgets. Do NOT use other color schemes.
Backgrounds
| Token | Value | Use |
|---|---|---|
| bg | #0d1117 | Page background |
| bgSurface | #161b22 | Cards, panels |
| bgElevated | #1c2128 | Hover states, elevated cards |
| bgOverlay | #21262d | Tooltips, dropdowns |
Text
| Token | Value | Use |
|---|---|---|
| text | #e6edf3 | Primary text |
| textSecondary | #8b949e | Labels, descriptions |
| textMuted | #484f58 | Disabled, placeholders |
Accent (Adom Teal)
| Token | Value | Use |
|---|---|---|
| accent | #00b8b0 | Primary accent, headings |
| accentBright | #00e6dc | Hover, focus, links |
| accentMuted | rgba(0, 184, 176, 0.12) | Tinted backgrounds |
Borders
| Token | Value | Use |
|---|---|---|
| border | #30363d | Standard borders |
| borderMuted | #21262d | Subtle borders |
| accentBorder | rgba(0, 184, 176, 0.3) | Accent borders |
Semantic
| Token | Value |
|---|---|
| success | #3fb950 |
| warning | #d29922 |
| danger | #f85149 |
Typography
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; /* code */
Radii
4px (small), 6px (medium), 8px (large)
HTML Template
Start every widget from this boilerplate:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117;
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 20px;
overflow-y: auto;
}
h1 { font-size: 18px; font-weight: 600; color: #00b8b0; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: #8b949e; margin-bottom: 16px; }
.card {
padding: 10px 12px;
background: #161b22;
border: 1px solid #21262d;
border-radius: 6px;
margin-bottom: 8px;
}
.card:hover { border-color: rgba(0,184,176,0.3); background: #1c2128; }
.accent { color: #00b8b0; }
.muted { color: #8b949e; font-size: 12px; }
.mono { font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; }
</style>
</head>
<body>
<h1>Title Here</h1>
<div class="subtitle">Description here</div>
<!-- Your content -->
<script>
// Interactive logic here (optional)
</script>
</body>
</html>
Workflow
- Write HTML to a temp file (e.g.,
/tmp/my-widget.html) using the template above - Push to AV using the curl command — escape the HTML into JSON with
python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" - Show the viewer — On the first push of the session, always call the
av_displayMCP tool (e.g.,mcp__adom-viewer__av_displayormcp__adom-viewer-2__av_display) to ensure the AV viewer panel is visible to the user. Subsequent pushes in the same session don't need this step since the viewer is already open. - Screenshot via mgmt server and read the image to show the user
- Iterate if the user wants changes — edit the HTML, re-push, re-screenshot
Push Pattern (Bash)
# Write HTML to temp file, then push
HTML_JSON=$(cat /tmp/my-widget.html | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
ID=$(python3 -c "import uuid; print(uuid.uuid4())")
curl -s http://127.0.0.1:8771/api/display -X POST \
-H "Content-Type: application/json" \
-d "{\"action\":\"display\",\"content\":{\"contentType\":\"html_interactive\",\"content\":${HTML_JSON},\"title\":\"My Widget\",\"id\":\"${ID}\",\"source\":\"av_display\",\"skill\":\"av-creator\",\"author\":\"John Lauer\"}}"
Screenshot Pattern (Bash)
RESULT=$(curl -s http://127.0.0.1:8772/ -X POST \
-H "Content-Type: application/json" \
-d '{"action":"screenshot"}')
FILE=$(echo "$RESULT" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['filePath'])")
# Then use the Read tool on $FILE to show the user
If the screenshot returns {"error":"No viewer connected"}, tell the user to open their Adom Viewer tab, then retry.
Tips
- Self-contained — no external CSS/JS dependencies. Everything inline.
- JSON escaping — always use the python3 JSON escape pattern. Raw HTML in curl breaks on quotes and newlines.
- Check status first —
curl -s http://127.0.0.1:8771/api/display -X POST -H "Content-Type: application/json" -d '{"action":"get_status"}'returns{ viewerCount, hasContent, historyLength }. - Grid layouts — use CSS Grid (
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))) for responsive card layouts. - Interactivity —
html_interactiveruns in an iframe with full JS support. Add click handlers, transitions, filters. - Large content — for very large HTML (>100KB), write to a file in
/home/adom/project/project-content/and use thedisplay_fileaction instead.
AI-Controllable Widgets
AV widgets can receive commands from Claude after they load. This enables Claude to toggle UI elements, update data, change views, highlight items, etc. without re-pushing the entire widget.
Pattern
The widget listens for postMessage commands from the parent:
<script>
// Signal readiness to parent
parent.postMessage({ type: 'widget_ready' }, '*');
// Listen for commands from Claude (via parent)
window.addEventListener('message', (e) => {
const msg = e.data;
if (!msg?.type) return;
switch (msg.type) {
case 'toggle_panel':
document.getElementById(msg.panelId).classList.toggle('hidden');
break;
case 'highlight_item':
document.querySelector(`[data-id="${msg.id}"]`).classList.add('highlighted');
break;
case 'update_data':
renderData(msg.data);
break;
case 'set_view':
switchView(msg.view);
break;
}
});
</script>
Sending Commands
After pushing the widget, send commands via the internal API:
# Send a command to the active widget's iframe
curl -s http://127.0.0.1:8771/api/display -X POST \
-H "Content-Type: application/json" \
-d '{"action":"post_message","message":{"type":"highlight_item","id":"resistor-1"}}'
Or via WebSocket: the parent relays post_message actions to the active iframe's contentWindow.postMessage().
Guidelines
- Always send
widget_readyso the parent knows when commands can be sent - Use descriptive
typenames (e.g.,toggle_toolbar,set_camera,show_caption) - Include a comment block at the top of
<script>listing supported commands - Keep command handling idempotent — sending the same command twice should be safe
Screenshot Support
Screenshots are critical in AV — they're how Claude verifies and shows work to the user. AV uses html2canvas in the parent document to capture any content, including html_interactive iframes. Your widget supports screenshots out of the box — no extra code needed.
How It Works
The AV viewer (index.html) loads html2canvas.min.js in the parent document. When a screenshot is requested via the mgmt server (POST http://127.0.0.1:8772/ with {"action":"screenshot"}), the captureOVScreenshot() function runs a 4-strategy fallback:
| Strategy | What it captures | How |
|---|---|---|
| 1: 3D iframe | 3D/Babylon.js content | postMessage to 3D iframe, which renders its canvas |
| 1b: html2canvas on iframe | html_interactive widgets | Clones iframe DOM + styles into an offscreen parent container, runs html2canvas on the clone |
| 2: SVG serialization | SVG content | Serializes SVG to image via XMLSerializer |
| 3: Image element | Static images | Draws <img> to canvas (with try/catch for cross-origin tainting) |
| 4: html2canvas on parent DOM | Any parent-rendered content | Runs html2canvas directly on contentEl |
Strategy 1b is the key one for html_interactive widgets. It works by:
- Accessing
iframe.contentDocument(same-origin becausesrcdociframes withallow-same-originshare the parent's origin) - Cloning all
<style>tags and body content into a temporary offscreen<div>in the parent - Copying computed styles (background, font, padding) from the iframe body
- Running
html2canvas(container, { scale: 2, useCORS: true })on the clone - Cleaning up the temporary container
Widget Author Guidelines
For most widgets — do nothing. The html2canvas pipeline handles standard DOM content automatically.
For best screenshot results:
- Use DOM elements, not
<canvas>— html2canvas captures DOM beautifully but can't read canvas pixel data - Keep images self-contained — use inline SVG or base64 data URIs instead of external image URLs. Cross-origin images may be tainted and render as blank
- Avoid
backdrop-filter— html2canvas doesn't support it; use solid backgrounds instead - Test with the screenshot command — after pushing your widget, always screenshot and verify
If Your Widget Uses <canvas>
html2canvas can't capture <canvas> pixel content. If your widget renders to canvas (charts, WebGL, etc.), add a custom capture handler that responds before the parent's html2canvas fallback:
<script>
window.addEventListener('message', (e) => {
if (e.data?.type === 'mgmt_capture_request') {
const canvas = document.getElementById('myCanvas');
parent.postMessage({
type: 'mgmt_canvas_capture',
_reqId: e.data._reqId,
data: canvas.toDataURL('image/png')
}, '*');
}
});
</script>
The parent listens for mgmt_canvas_capture responses. If your widget responds, Strategy 1 handles it and the later strategies are skipped.
Rule #5: Every New Widget Gets the Full Treatment
This is non-negotiable. When you create ANY new widget or skill that pushes content to AV — whether it's a standalone view, a skill showcase, a dashboard, a tool, or anything else — you MUST do ALL of the following. There is NO distinction between "pushed content" and "views" for this rule. If it shows up in AV, it gets the full treatment.
- Add a unique tab icon — Add a skill-level icon check in
tabIconSvg()inviewer/viewer/index.htmlforitem.skill === 'your-skill-name'. Every widget MUST have its own distinct 14x14px inline SVG icon (stroke color${c},stroke-width="1",fill="none"). The generic</>code brackets icon is NOT acceptable for any named skill/widget. - Add it to the dropdown — Add an
<option>to the<select id="view-select">inviewer/viewer/index.htmlwith its View ID and display name (e.g.,<option value="myview">MyView — My New View</option>). - Add it to VIEW_RENDERERS — Register
viewId,title,author, andrenderfunction in theVIEW_RENDERERSobject inindex.html. Theauthorfield MUST be the full name of the person who created the view (from git history or skill frontmatter) — NOT "Adom Viewer" or any generic name. - Add it to the About page — Add a skill card in the
showAbout()function so users can discover it. - Add it to the Skills Map — Add an entry in
viewer/viewer/skills-map.htmlso it appears in the skills map visualization. - Ensure screenshot support works — Two options:
- DOM-based content — html2canvas handles it automatically via Strategy 1b/4. Nothing extra needed.
- Canvas-based content — implement the
mgmt_capture_request→mgmt_canvas_capturepostMessage handler shown above. This is what3d.htmldoes for Babylon.js captures.
Why this matters: Without the full treatment, the widget looks like a second-class citizen — generic icon, not discoverable in the dropdown, missing from About/Skills Map. Every widget is an app. Treat it like one.
Never ship a widget that can't be screenshotted. If the mgmt screenshot returns "No capturable content visible", your viewer is broken — fix it before deploying.
Tab Switching for Multi-Tab Screenshots
To programmatically switch tabs before screenshotting (e.g., to capture each tab in sequence):
# Switch to tab at index 0
curl -s http://127.0.0.1:8771/api/display -X POST \
-H "Content-Type: application/json" \
-d '{"action":"switch_tab","index":0}'
# Wait for iframe to render
sleep 3
# Screenshot
curl -s http://127.0.0.1:8772/ -X POST \
-H "Content-Type: application/json" \
-d '{"action":"screenshot"}'
Tab indices are 0-based in the order content was pushed.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Invalid JSON | HTML not properly escaped | Use the python3 JSON escape pattern |
viewerCount: 0 | No browser tab open | Tell user to open Adom Viewer tab |
No viewer connected on screenshot | Viewer tab not connected | User needs to open/refresh the AV tab |
| White text invisible | Using light theme colors | Stick to the dark theme tokens above |
| Content too wide | No overflow handling | Add overflow-x: auto or word-break: break-word |
| Edits not showing after re-push | Browser iframe cache | See Iframe Caching below |
| Cross-origin fetch fails in srcdoc | Null origin in av_display_file | Use iframe approach instead (see below) |
Iframe Caching — STOP Before You Loop
This is the #1 time-waster when iterating on AV widgets. You edit a file, re-push to AV, but the old code still runs. You then spend 30+ minutes fighting phantom bugs, pushing the same iframe over and over.
Before debugging anything else, check these in order:
Are you editing the right file? If a file server serves from
dir/, make sure you're editing indir/— not a duplicate copy elsewhere. Run:curl http://127.0.0.1:PORT/file.html | grep "your_new_function"— if it returns 0, you're editing the wrong copy.Is the browser serving a cached version? Push with a unique filename instead:
cp widget.html widget-$(date +%s).htmland point the iframe at the new name. The browser literally cannot cache a file it's never seen.Stop after 2 failed attempts. If re-pushing the same iframe URL twice doesn't show your changes, the problem is NOT "push it again." It's either (a) wrong file, (b) browser cache, or (c) a JS error crashing the widget before your changes execute. Check the browser console.
srcdoc vs Iframe Origin
av_display_file and av_display inject content as srcdoc in an iframe. This gives a null origin, which means:
- Cross-origin fetches fail (CORS blocks requests from null origin)
location.originreturns"null"(string), not a real originparent.locationthrows if the parent is on a different origin
If your widget needs network access (fetching APIs, loading 3D tiles, etc.), you MUST serve it from a real file server and push an iframe wrapper:
<iframe src="https://<widget-url>.adom.cloud/widget.html" style="width:100%;height:100%;border:none"></iframe>
This gives the widget a real origin and full network access.
CesiumJS / Heavy 3D Widgets in AV
CesiumJS (and similar WebGL-heavy libraries) have special challenges in AV:
Two approaches — tradeoffs:
| Approach | Pros | Cons |
|---|---|---|
av_display_file (inline srcdoc) | av_capture sees HTML/DOM overlays; single push | Null origin — location.origin is "null" (string). Must hardcode absolute URLs. CESIUM_BASE_URL must be set before loading CesiumJS so workers resolve correctly. av_capture CANNOT see WebGL canvas content. |
| iframe wrapper (file server) | Real origin — URL detection works naturally; full network access | av_capture sees DOM overlays but NOT WebGL canvas (cross-origin restriction). Must have file server running. |
Key fixes for both approaches:
Set
CESIUM_BASE_URLbefore the CesiumJS<script>tag — CesiumJS uses this to find its web workers. Without it, workers fail silently in srcdoc/iframe contexts:<script>window.CESIUM_BASE_URL = 'https://cesium.com/downloads/cesiumjs/releases/1.119/Build/Cesium/';</script> <script src="https://cesium.com/downloads/cesiumjs/releases/1.119/Build/Cesium/Cesium.js"></script>URL detection for proxied services — When running inside AV (either srcdoc or nested iframe),
location.originmay be"null"or different from expected. Uselocation.hrefregex matching with a hardcoded fallback:const CODER_BASE = (() => { const href = location.href; const m = href.match(/^(https?:\/\/[^/]+)/); if (m && m[1] !== 'null' && !href.startsWith('about:')) return m[1]; try { const ph = parent.location.href; const m2 = ph.match(/^(https?:\/\/[^/]+)/); if (m2) return m2[1]; } catch {} return 'https://coder.<your-slug>.containers.adom.inc'; // hardcoded fallback })(); const TILE_PROXY = CODER_BASE + '/proxy/8793';CORS proxy for external tiles — OSM tiles (
tile.openstreetmap.org) don't send CORS headers. Google 3D Tiles need API key injection. Proxy both through a local Node server that addsAccess-Control-Allow-Origin: *. For Google 3D Tiles, rewrite JSON"uri"fields to route through the proxy.av_capture limitation — av_capture CANNOT screenshot WebGL canvas content inside cross-origin iframes. The toolbar/DOM overlays will be visible but the 3D globe/scene will appear black. This is a browser security restriction, not a bug. Use
av_tab_capture(requires capture companion tab setup) to capture the full tab including WebGL content.Prefer inline (
av_display_file) for screenshotability — If you need av_capture to see the toolbar and UI (even though WebGL is black), inline is better because at least DOM elements render. The iframe approach may show nothing at all if the tab doesn't switch properly.