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

  1. View: ClaudeApi — the widget name, so users learn what's generating content
  2. Author: John Lauer (@john) — who made this
  3. 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:

  1. Call av_reload (uses mgmt relay on port 8772, works even during server restart)
  2. If av_reload reports 0 viewers connected, try once more after 3 seconds
  3. 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 content
  • skill: 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: true is set on the content, it renders in a padded div; otherwise in a fullscreen div.

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

  1. Write HTML to a temp file (e.g., /tmp/my-widget.html) using the template above
  2. Push to AV using the curl command — escape the HTML into JSON with python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))"
  3. Show the viewer — On the first push of the session, always call the av_display MCP tool (e.g., mcp__adom-viewer__av_display or mcp__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.
  4. Screenshot via mgmt server and read the image to show the user
  5. 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 firstcurl -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.
  • Interactivityhtml_interactive runs 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 the display_file action 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_ready so the parent knows when commands can be sent
  • Use descriptive type names (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:

  1. Accessing iframe.contentDocument (same-origin because srcdoc iframes with allow-same-origin share the parent's origin)
  2. Cloning all <style> tags and body content into a temporary offscreen <div> in the parent
  3. Copying computed styles (background, font, padding) from the iframe body
  4. Running html2canvas(container, { scale: 2, useCORS: true }) on the clone
  5. 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.

  1. Add a unique tab icon — Add a skill-level icon check in tabIconSvg() in viewer/viewer/index.html for item.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.
  2. Add it to the dropdown — Add an <option> to the <select id="view-select"> in viewer/viewer/index.html with its View ID and display name (e.g., <option value="myview">MyView — My New View</option>).
  3. Add it to VIEW_RENDERERS — Register viewId, title, author, and render function in the VIEW_RENDERERS object in index.html. The author field 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.
  4. Add it to the About page — Add a skill card in the showAbout() function so users can discover it.
  5. Add it to the Skills Map — Add an entry in viewer/viewer/skills-map.html so it appears in the skills map visualization.
  6. Ensure screenshot support works — Two options:
    1. DOM-based content — html2canvas handles it automatically via Strategy 1b/4. Nothing extra needed.
    2. Canvas-based content — implement the mgmt_capture_requestmgmt_canvas_capture postMessage handler shown above. This is what 3d.html does 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:

  1. Are you editing the right file? If a file server serves from dir/, make sure you're editing in dir/ — 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.

  2. Is the browser serving a cached version? Push with a unique filename instead: cp widget.html widget-$(date +%s).html and point the iframe at the new name. The browser literally cannot cache a file it's never seen.

  3. 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.origin returns "null" (string), not a real origin
  • parent.location throws 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:

  1. Set CESIUM_BASE_URL before 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>
    
  2. URL detection for proxied services — When running inside AV (either srcdoc or nested iframe), location.origin may be "null" or different from expected. Use location.href regex 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';
    
  3. 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 adds Access-Control-Allow-Origin: *. For Google 3D Tiles, rewrite JSON "uri" fields to route through the proxy.

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

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