AgentSkillsCN

Canvas

画布

SKILL.md

Vanilla Canvas Skill

Build interactive UIs with vanilla JavaScript, semantic HTML, and pure CSS. No frameworks. No build step.

Philosophy

  • Vanilla JS - Modern JavaScript (ESM, async/await, fetch), no TypeScript
  • Native HTML - Use <select>, <input type="date">, <dialog>, <details> over JS equivalents
  • Semantic HTML - Structure with <main>, <nav>, <header>, <footer>, <article>, <section>
  • Pure CSS - Variables, modern features, meaningful class names. No Tailwind.
  • No build step - Files served directly. Import maps for libraries.

Creating a Canvas

Write an index.html file to .claude/artifacts/<canvas-id>/:

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="/base.css">
  <style>
    /* Canvas-specific styles using CSS variables */
    .dashboard {
      display: grid;
      gap: var(--space-4);
      padding: var(--space-6);
    }
  </style>
</head>
<body>
  <main class="dashboard">
    <article class="card">
      <header class="card-header">
        <h2>My Dashboard</h2>
      </header>
      <div class="card-body">
        <p>Content here</p>
      </div>
    </article>
  </main>

  <script type="module">
    // Your JavaScript here
  </script>
</body>
</html>

File Protocol

FilePurpose
index.htmlYour canvas (HTML + CSS + JS)
_state.jsonTwo-way state sync
_log.jsonlEvents and errors
_screenshot.pngScreenshot output

State Sync

javascript
// Read state
const state = await window.canvasState()

// Update state (writes to _state.json, notifies server)
await window.canvasState({ ...state, count: state.count + 1 })

// Listen for external state changes
window.addEventListener("canvas-state-change", (e) => {
  console.log("State changed:", e.detail)
})

Emitting Events

javascript
// Emit event (written to _log.jsonl)
window.canvasEmit("button-clicked", { id: "submit" })
window.canvasEmit("form-submitted", { name: "John", email: "john@example.com" })

Native HTML Patterns

Dialog (Modal)

html
<dialog id="confirm-dialog">
  <form method="dialog">
    <h3>Confirm Action</h3>
    <p>Are you sure you want to proceed?</p>
    <footer class="flex gap-2" style="margin-top: var(--space-4)">
      <button value="cancel" class="secondary">Cancel</button>
      <button value="confirm">Confirm</button>
    </footer>
  </form>
</dialog>

<script type="module">
  const dialog = document.getElementById("confirm-dialog")

  // Show dialog
  dialog.showModal()

  // Handle result
  dialog.addEventListener("close", () => {
    if (dialog.returnValue === "confirm") {
      // User confirmed
    }
  })
</script>

Accordion

html
<details>
  <summary>Click to expand</summary>
  <p>Hidden content revealed when expanded.</p>
</details>

<!-- Multiple items -->
<details name="accordion">
  <summary>Section 1</summary>
  <p>Content 1</p>
</details>
<details name="accordion">
  <summary>Section 2</summary>
  <p>Content 2</p>
</details>

Tabs

html
<div class="tabs" role="tablist">
  <button role="tab" aria-selected="true" data-tab="tab1">Tab 1</button>
  <button role="tab" data-tab="tab2">Tab 2</button>
</div>

<div id="tab1" role="tabpanel">Content 1</div>
<div id="tab2" role="tabpanel" hidden>Content 2</div>

<script type="module">
  document.querySelectorAll("[role=tab]").forEach(tab => {
    tab.addEventListener("click", () => {
      // Update aria-selected
      document.querySelectorAll("[role=tab]").forEach(t => {
        t.setAttribute("aria-selected", t === tab)
      })
      // Show/hide panels
      document.querySelectorAll("[role=tabpanel]").forEach(panel => {
        panel.hidden = panel.id !== tab.dataset.tab
      })
    })
  })
</script>

Date Picker

html
<label for="date">Select date</label>
<input type="date" id="date" min="2024-01-01" max="2025-12-31">

Select (Dropdown)

html
<label for="country">Country</label>
<select id="country">
  <option value="">Select a country</option>
  <option value="us">United States</option>
  <option value="uk">United Kingdom</option>
  <option value="ca">Canada</option>
</select>

Using External Libraries

Use import maps for charting, mapping, or SQLite:

html
<script type="importmap">
{
  "imports": {
    "chart.js": "https://cdn.jsdelivr.net/npm/chart.js@4/+esm",
    "chart.js/auto": "https://cdn.jsdelivr.net/npm/chart.js@4/auto/+esm"
  }
}
</script>

<canvas id="chart"></canvas>

<script type="module">
  import Chart from "chart.js/auto"

  new Chart(document.getElementById("chart"), {
    type: "bar",
    data: {
      labels: ["Jan", "Feb", "Mar"],
      datasets: [{
        label: "Sales",
        data: [100, 150, 200]
      }]
    }
  })
</script>

CSS Variables (from base.css)

css
/* Colors */
--color-bg, --color-fg, --color-muted, --color-primary
--color-border, --color-input, --color-danger, --color-success

/* Spacing */
--space-1 (0.25rem), --space-2 (0.5rem), --space-3 (0.75rem)
--space-4 (1rem), --space-6 (1.5rem), --space-8 (2rem)

/* Radii */
--radius-sm (0.25rem), --radius (0.375rem), --radius-lg (0.5rem)

/* Shadows */
--shadow-sm, --shadow, --shadow-lg

/* Typography */
--font-sans, --font-mono
--text-xs, --text-sm, --text-base, --text-lg, --text-xl, --text-2xl

Utility Classes (from base.css)

css
.container    /* max-width centered container */
.card         /* bordered card with shadow */
.card-header  /* card header with border */
.card-body    /* card content area */
.card-footer  /* card footer with background */

.flex, .flex-col, .items-center, .justify-between
.gap-2, .gap-4
.text-sm, .text-muted, .font-medium, .font-bold
.sr-only      /* screen reader only */

Button Variants

html
<button>Primary</button>
<button class="secondary">Secondary</button>
<button class="danger">Danger</button>
<button class="ghost">Ghost</button>

Form Example

html
<form id="signup-form">
  <div style="display: grid; gap: var(--space-4);">
    <div>
      <label for="name">Name</label>
      <input type="text" id="name" required>
    </div>
    <div>
      <label for="email">Email</label>
      <input type="email" id="email" required>
    </div>
    <div>
      <label for="plan">Plan</label>
      <select id="plan" required>
        <option value="">Select a plan</option>
        <option value="free">Free</option>
        <option value="pro">Pro ($9/mo)</option>
      </select>
    </div>
    <div>
      <label for="start">Start Date</label>
      <input type="date" id="start">
    </div>
    <button type="submit">Sign Up</button>
  </div>
</form>

<script type="module">
  document.getElementById("signup-form").addEventListener("submit", (e) => {
    e.preventDefault()
    const data = Object.fromEntries(new FormData(e.target))
    window.canvasEmit("signup", data)
  })
</script>

Data Table

html
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Email</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody id="users-table">
    <!-- Rows inserted by JS -->
  </tbody>
</table>

<script type="module">
  const users = [
    { name: "Alice", email: "alice@example.com", status: "Active" },
    { name: "Bob", email: "bob@example.com", status: "Pending" }
  ]

  document.getElementById("users-table").innerHTML = users.map(u => `
    <tr>
      <td>${u.name}</td>
      <td>${u.email}</td>
      <td>${u.status}</td>
    </tr>
  `).join("")
</script>

CanvasClient API (for operations)

javascript
import { CanvasClient } from "./vanilla/client.js"

const client = await CanvasClient.fromServerJson()
await client.screenshot("my-app")     // Take screenshot
await client.close("my-app")          // Close canvas
await client.getState("my-app")       // Read state
await client.setState("my-app", {...}) // Write state
await client.list()                   // List all canvases

Starting the Server

bash
cd tools/browser-canvas/vanilla
bun server.js

Server runs on port 3210 by default. Open http://localhost:3210 in browser.