npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@design-canvas/toolbox

v0.4.28

Published

A composable plugin ecosystem for developer tools — floating toolbar, element inspector, design tokens, annotations, and more.

Downloads

6,317

Readme

Design Canvas

A composable plugin ecosystem for developer tools. Drop it into any project, toggle tools on/off from a floating toolbar, and let plugins share services like notifications, storage, element picking, GitHub issues, screenshots, and more — all without a server or database.


Quick Start

1. Install

npm install @design-canvas/toolbox

2. Initialize project

Run the init command to scaffold config files and copy AI skills into your project:

npx design-canvas init

This creates:

  • .github/skills/dc-* — AI agent skills for Copilot / Claude
  • .designcanvas.json — default plugin configuration
  • .checks/registry.json — empty checks registry

After upgrading Design Canvas, re-run with --force to update skills to the latest version:

npx design-canvas init --force

Config files (.designcanvas.json, .checks/registry.json) are never overwritten — only skills are refreshed.

3. Add the Vite plugin

// vite.config.ts
import { designCanvas } from '@design-canvas/toolbox/loader/vite';

export default defineConfig({
  plugins: [designCanvas()],
});

That's it. The Vite plugin auto-injects the loader into your HTML, mounts the toolbar, and boots all configured plugins. Your app code stays untouched.

4. Configure plugins

Create a .designcanvas.json in your project root:

{
  // Which plugins to load
  "plugins": {
    "notes":          { "enabled": true },
    "inspector":      { "enabled": true },
    "checks":         { "enabled": true },
    "style-editor":   { "enabled": true },
    "storybook-bridge": { "enabled": true },

    // Per-plugin panel positioning (optional)
    "my-plugin": {
      "enabled": true,
      "panel": {
        "defaultPosition": "top-right",   // "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | { "x": 100, "y": 200 }
        "defaultDock": "right",            // "left" | "right" | "bottom" (overrides plugin default)
        "defaultSize": { "width": 400, "height": 600 }
      }
    }
  },

  // Toolbar appearance
  "toolbar": {
    "position": "right",   // "left" | "right" | "top" | "bottom"
    "theme": "auto"        // "light" | "dark" | "auto"
  }
}

JSONC comments are supported. The config file is watched — changes trigger a full page reload automatically.

Panel Position Priority

When a plugin panel opens, position is resolved in this order:

  1. localStorage — if the user previously dragged/resized the panel, that position is restored
  2. .designcanvas.json — the panel.defaultPosition for that plugin (if configured)
  3. Center — falls back to centering the panel on screen

Alternative: Manual initialization

If you don't use Vite or want more control:

import { init } from '@design-canvas/toolbox/loader';

await init({
  toolbar: { position: 'bottom', theme: 'dark' },
});

Architecture

┌──────────────────────────────────────────────┐
│              Host App (any repo)             │
├──────────────────────────────────────────────┤
│                Floating Toolbar              │
│  [ ⬡ ] [ 📝 ] [ 🔍 ] [ 🎨 ] [ ✓ ] [ 📖 ] │
├──────────────────────────────────────────────┤
│       Plugins (each independently toggled)   │
│  ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│  │  Notes   │ │Inspector │ │ Style Editor │ │
│  │  🟢 on   │ │  🔴 off  │ │   🟢 on      │ │
│  └──────────┘ └──────────┘ └──────────────┘ │
├──────────────────────────────────────────────┤
│            Shared Services Layer             │
│  notify · errors · storage · log · commands  │
│  clipboard · elementPicker · github ·        │
│  screenshot · notes · copilot · ...          │
├──────────────────────────────────────────────┤
│             Runtime Kernel (core)            │
│  lifecycle · event bus · service registry    │
└──────────────────────────────────────────────┘

The system has three layers:

  1. Runtime Kernel — manages plugin lifecycle, an event bus for inter-plugin communication, and a service registry.
  2. Shared Services — global capabilities (notifications, storage, element picking, GitHub, etc.) that any plugin can use without knowing the implementation.
  3. Plugins — self-contained tools that render into scoped DOM containers, communicate through events, and consume services.

Plugin Lifecycle

Every plugin goes through a state machine:

idle ──► loading ──► active ◄──► suspended
                       │
                       ▼
                     error

| State | Description | | --- | --- | | idle | Registered but never activated. No DOM, no listeners. | | loading | activate() has been called, waiting for async setup. | | active | Running — DOM mounted, listeners attached, UI visible. | | suspended | UI torn down, but in-memory state is preserved. Re-activating is instant. | | error | Activation failed. Plugin is inert until retried. |

Key: Suspending is NOT unloading. The plugin keeps its data (annotations, unsaved edits, etc.) and restores instantly when re-activated.


Creating a Custom Plugin

A plugin is a single object that implements PluginDefinition. Here's a minimal example:

import type { PluginDefinition, PluginContext } from '@design-canvas/toolbox/core';

export const myPlugin: PluginDefinition = {
  meta: {
    id: 'my-plugin',
    name: 'My Plugin',
    icon: '🚀',                    // emoji, SVG string, or URL
    description: 'Does cool things',
    version: '1.0.0',
    panel: {
      defaultSize: { width: 320, height: 400 },
      minSize: { width: 280, height: 200 },
      resizable: true,
      showStatusBar: true,
      defaultDock: 'right',        // 'left' | 'right' | 'bottom'
      defaultPosition: 'center',   // 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | { x, y }
    },
  },

  activate(ctx: PluginContext) {
    // ctx.mountPoint is your scoped DOM container
    ctx.mountPoint.innerHTML = `
      <div style="padding: 16px;">
        <h3>Hello from My Plugin!</h3>
        <button id="my-btn">Click me</button>
      </div>
    `;

    const btn = ctx.mountPoint.querySelector('#my-btn')!;
    btn.addEventListener('click', () => {
      ctx.services.notify.toast('Button clicked!', 'success');
    });
  },

  suspend() {
    // Optional: tear down UI while keeping in-memory state.
    // Called when the user toggles the plugin off.
  },

  deactivate() {
    // Optional: full cleanup — release everything.
    // Called when the plugin is unregistered.
  },

  getAPI() {
    // Optional: return a public API other plugins can call
    // via ctx.getPlugin('my-plugin')
    return {
      doSomething() { /* ... */ },
    };
  },

  getHandoffContext() {
    // Optional: contribute data to a handoff payload
    return { notes: ['...'] };
  },
};

Plugin Definition Reference

interface PluginDefinition {
  meta: {
    id: string;           // Unique identifier (kebab-case)
    name: string;         // Display name in toolbar
    icon: string;         // SVG string, emoji, or URL
    description?: string;
    version?: string;
    panel?: {
      defaultSize?: { width: number; height: number };
      minSize?: { width: number; height: number };
      resizable?: boolean;
      showStatusBar?: boolean;
      defaultDock?: 'left' | 'right' | 'bottom';
    };
  };

  skills?: SkillDefinition[];                     // AI agent skills (see below)
  activate(ctx: PluginContext, config?): void | Promise<void>;
  suspend?(): void;
  deactivate?(): void;
  getAPI?(): unknown;
  getHandoffContext?(): unknown;
}

Plugin Context

When activate() is called, the runtime passes a PluginContext with everything your plugin needs:

interface PluginContext {
  mountPoint: HTMLElement;         // Scoped DOM container for your UI
  panel: PanelHandle;             // Control the panel (open, close, dock, resize)
  services: ServiceMap;           // All shared services
  isDark: boolean;                // Current theme (for styling)

  emit(type: string, payload?: unknown): void;       // Publish event
  on(type: string, handler): Disposable;              // Subscribe to events
  getPlugin(id: string): unknown | null;              // Access another plugin's API
  storage: PluginStorage;                              // Per-plugin scoped persistence
  badge: BadgeHandle;                                  // Set a badge count on your toolbar icon
}

Mount Point

ctx.mountPoint is a <div> scoped to your plugin. Render your entire UI here — plain DOM, or wire up any framework (React, Svelte, etc.).

Panel Control

ctx.panel.open();                       // Show the panel
ctx.panel.close();                      // Hide it
ctx.panel.collapse();                   // Minimize to title bar
ctx.panel.expand();                     // Restore from collapsed
ctx.panel.dock('right');                // Dock to an edge
ctx.panel.undock();                     // Float freely
ctx.panel.setPreferredSize(400, 500);   // Set dimensions
ctx.panel.setStatus('3 items');         // Status bar text
ctx.panel.setStatusActions([            // Status bar buttons
  { label: 'Export', icon: '📤', onClick: () => exportData() },
]);

Scoped Storage

Each plugin gets its own namespaced key-value store (backed by localStorage under dc:<pluginId>:<key>):

ctx.storage.set('lastFilter', 'unresolved');
const filter = ctx.storage.get<string>('lastFilter'); // 'unresolved'
ctx.storage.delete('lastFilter');

Badge

Show a count on your toolbar icon:

ctx.badge.set(5);    // Shows "5" badge
ctx.badge.clear();   // Removes badge

Inter-Plugin Communication

Plugins communicate via a shared event bus:

// Plugin A: publish
ctx.emit('element:selected', { selector: '.header', tagName: 'div' });

// Plugin B: subscribe
const sub = ctx.on('element:selected', (event) => {
  console.log(event.source);   // 'plugin-a'
  console.log(event.payload);  // { selector: '.header', tagName: 'div' }
  console.log(event.timestamp);
});

// Later: unsubscribe
sub.dispose();

Subscribe to all events with ctx.on('*', handler).

Accessing Other Plugins

const notesAPI = ctx.getPlugin('notes');
if (notesAPI) {
  notesAPI.addNote('This element needs attention');
}

This returns whatever getAPI() returns from the target plugin (or null if it's not active).


Shared Services

Every plugin has access to the full service layer via ctx.services. Here's what's available:

Notify — ctx.services.notify

User-facing notifications.

ctx.services.notify.toast('Saved!', 'success');           // 'info' | 'success' | 'warning' | 'error'
const confirmed = await ctx.services.notify.confirm('Delete this item?'); // true/false
const progress = ctx.services.notify.progress('Exporting...');
progress.update(50);   // percent
progress.done();

Service Unavailable Banners

When a plugin depends on an optional service (GitHub, Copilot, Notes sync, etc.), use serviceUnavailable() instead of a bare toast. It renders a rich notification with the service's setup guide and the list of features the user is missing:

if (!ctx.services.github.available) {
  ctx.services.notify.serviceUnavailable({
    service: 'github',
    pluginName: 'My Plugin',
    features: ['Create issues from annotations', 'Assign collaborators'],
  });
  return;
}

The banner automatically looks up the named service's setupGuide and renders inline setup instructions (e.g. "Install gh CLI", "Run npx design-canvas serve"). It auto-dismisses after 10 seconds or on click.

All server-dependent services expose a consistent available / setupGuide pair:

| Service | available | setupGuide | |---------|-------------|---------------| | github | ✅ | ✅ | | copilot | ✅ | ✅ | | screenshot | ✅ | ✅ | | notes | ✅ | ✅ | | design | ✅ | ✅ |

Errors — ctx.services.errors

Centralized error capture and display.

ctx.services.errors.report({
  source: 'my-plugin',
  message: 'Failed to load data',
  severity: 'error',          // 'warning' | 'error' | 'fatal'
  error: err,                 // original Error object
  context: { url: '/api/data' },
});

const allErrors = ctx.services.errors.getAll();
ctx.services.errors.clear();

// Listen for errors from any source
ctx.services.errors.onError((entry) => {
  console.log(`${entry.source}: ${entry.message}`);
});

Storage — ctx.services.storage

Global key-value persistence (localStorage). For plugin-scoped storage, use ctx.storage instead.

ctx.services.storage.set('global-setting', value);
const val = ctx.services.storage.get<string>('global-setting');
ctx.services.storage.delete('global-setting');

Log — ctx.services.log

Structured logging.

ctx.services.log.debug('Detailed info', extraData);
ctx.services.log.info('Plugin initialized');
ctx.services.log.warn('Deprecated usage');
ctx.services.log.error('Something broke', err);

Commands — ctx.services.commands

Register keyboard shortcuts.

const disposable = ctx.services.commands.register(
  'my-plugin:search',
  'Ctrl+Shift+F',
  () => openSearch(),
);

// Execute a registered command
ctx.services.commands.execute('my-plugin:search');

// List all registered commands
const all = ctx.services.commands.list(); // [{ id, shortcut }]

Clipboard — ctx.services.clipboard

await ctx.services.clipboard.copy('Hello, clipboard!');
const text = await ctx.services.clipboard.read();

Element Picker — ctx.services.elementPicker

A shared DOM element selection tool. When a user needs to point at an element — for inspecting, annotating, measuring — use this service. All plugins share the same hover/highlight/click interaction.

// One-shot pick
const result = await ctx.services.elementPicker.pick({
  prompt: 'Select an element to inspect',
  highlightColor: '#0078d4',
});

if (result) {
  console.log(result.element);        // HTMLElement
  console.log(result.selector);       // CSS selector string
  console.log(result.displayPath);    // 'div.container > section > h1'
  console.log(result.rect);           // DOMRect
  console.log(result.computedStyles); // key CSS properties
}

// Continuous pick mode (keeps picking until cancelled)
ctx.services.elementPicker.pick({
  continuous: true,
  prompt: 'Click elements to inspect — Esc to stop',
});

// Listen for pick events
ctx.services.elementPicker.onPick((event) => {
  // event.type: 'start' | 'hover' | 'select' | 'deselect' | 'cancel'
  if (event.type === 'select') {
    console.log('Selected:', event.element, event.selector);
  }
});

// Cancel picking
ctx.services.elementPicker.cancel();

// Check state
ctx.services.elementPicker.isPicking;     // boolean
ctx.services.elementPicker.requestedBy;   // plugin ID or null

GitHub — ctx.services.github

GitHub integration (issues, users, collaborators). Requires a dev server with a GitHub token.

if (ctx.services.github.available) {
  // User info
  const user = await ctx.services.github.getUser();
  console.log(user?.login, user?.avatarUrl);

  // Collaborators
  const collabs = await ctx.services.github.getCollaborators();

  // Issues
  const issue = await ctx.services.github.createIssue({
    title: 'Fix padding on header',
    body: '## Description\nPadding is 8px, should be 16px.',
    labels: ['design', 'css'],
    assignees: ['username'],
  });
  console.log(issue.url); // https://github.com/owner/repo/issues/42

  const issues = await ctx.services.github.listIssues();
  await ctx.services.github.updateIssueState(42, 'closed');

  // Assign GitHub Copilot to an issue
  await ctx.services.github.assignCopilot(42);
} else {
  console.log(ctx.services.github.setupGuide); // instructions to enable
}

Screenshot — ctx.services.screenshot

Capture screenshots of DOM elements (requires the dev server with Playwright).

if (ctx.services.screenshot.available) {
  const dataUrl = await ctx.services.screenshot.capture('.my-component');
  if (dataUrl) {
    img.src = dataUrl; // base64 PNG
  }
}

Notes — ctx.services.notes

Shared annotation persistence (synced via the dev server). Falls back to localStorage-only when the server is unavailable. Any plugin can create annotations programmatically.

if (!ctx.services.notes.available) {
  console.log(ctx.services.notes.setupGuide); // instructions to enable sync
}

const notes = await ctx.services.notes.list();
await ctx.services.notes.save({
  id: crypto.randomUUID(),
  text: 'Padding needs to match the design spec',
  url: location.pathname,
  selector: '.hero-section',
  elementLabel: 'Hero Section',
  timestamp: Date.now(),
  updatedAt: Date.now(),
  source: 'my-plugin',                       // identifies the creating plugin
  severity: 'warning',                        // 'info' | 'warning' | 'error' (optional)
  status: 'open',                             // 'open' | 'dismissed' | 'closed' (optional, default: 'open')
  author: { login: 'octocat', name: 'Mona' }, // optional
});
await ctx.services.notes.remove(noteId);

// Real-time sync — fires when notes change (polling or other tabs)
ctx.services.notes.onRemoteChange((updatedNotes) => {
  refreshUI(updatedNotes);
});

Creating Annotations from a Plugin

Plugins can act as annotation sources by setting the source field. This allows filtering annotations by origin in the Annotations panel:

// In your plugin's activate():
async function annotate(selector: string, message: string, severity?: 'info' | 'warning' | 'error') {
  await ctx.services.notes.save({
    id: crypto.randomUUID(),
    text: message,
    url: location.pathname,
    selector,
    elementLabel: document.querySelector(selector)?.tagName.toLowerCase(),
    timestamp: Date.now(),
    updatedAt: Date.now(),
    source: ctx.meta.id,    // your plugin's ID — shows in the Source filter
    severity,
  });
}

// Usage
annotate('.nav-link', 'Color contrast ratio is 3.2:1, needs 4.5:1', 'error');
annotate('#hero img', 'Missing alt text', 'warning');

SharedNote Interface

interface SharedNote {
  id: string;
  text: string;
  url: string;                // page pathname
  selector?: string;          // CSS selector of the annotated element
  elementLabel?: string;      // human-readable element description
  screenshotDataUrl?: string; // base64 PNG thumbnail
  timestamp: number;
  updatedAt: number;
  author?: { login: string; name: string | null; avatarUrl?: string };
  issue?: { number: number; url: string };
  source?: string;            // plugin ID that created the note
  severity?: 'info' | 'warning' | 'error';
  status?: 'open' | 'dismissed' | 'closed';
}

Copilot — ctx.services.copilot

AI-powered assistance (requires the dev server with a Copilot endpoint).

if (ctx.services.copilot.available) {
  // Free-form chat
  const response = await ctx.services.copilot.ask([
    { role: 'system', content: 'You are a CSS expert.' },
    { role: 'user', content: 'How do I center a div?' },
  ]);

  // Shorthand helpers
  const explanation = await ctx.services.copilot.explain('grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))');
  const suggestion = await ctx.services.copilot.suggest('A responsive card layout with gap');
}

Issue Templates — ctx.services.issueTemplates

Format structured data into GitHub issue bodies.

const { title, body, labels } = ctx.services.issueTemplates.formatStyleChange(
  [{ selector: '.btn', property: 'padding', oldValue: '8px', newValue: '16px' }],
  { framework: 'Tailwind', pageUrl: location.href },
);
// → title: "Style change: .btn padding"
// → body: formatted Markdown with before/after table
// → labels: ['design-canvas', 'style-change']

Bundling AI Skills with Plugins

Plugins can bundle skill definitions that get installed as .github/skills/dc-* files. These teach AI agents (GitHub Copilot, Claude, etc.) how to use your plugin.

export const myPlugin: PluginDefinition = {
  meta: { id: 'my-plugin', name: 'My Plugin', icon: '🚀' },

  skills: [
    {
      id: 'dc-my-action',
      name: 'My Custom Action',
      description: 'Use when the user asks to do X. Triggers on "do X", "run X".',
      instructions: `## Workflow
1. Open the My Plugin panel from the toolbar
2. Click the action button
3. Review the output

## Capabilities
- Does X, Y, and Z
- Supports A and B`,
    },
  ],

  activate(ctx) { /* ... */ },
};

Skills are automatically installed to the project's .github/skills/ directory when the plugin loads (via the dev server).


Publishing a Plugin as an npm Package

The loader resolves plugins by ID. After checking built-in plugins, it tries npm packages with these naming conventions:

@design-canvas/plugin-<id>    (scoped)
design-canvas-plugin-<id>     (unscoped)

Your package should export the plugin definition:

// package: @design-canvas/plugin-my-tool
import type { PluginDefinition } from '@design-canvas/toolbox/core';

export const plugin: PluginDefinition = {
  meta: { id: 'my-tool', name: 'My Tool', icon: '🔧' },
  activate(ctx) { /* ... */ },
};

// Also works: export default, or export { myToolPlugin }

Users then add it to their config:

{
  "plugins": {
    "my-tool": { "enabled": true }
  }
}

Packages

| Package | Path | Description | | --- | --- | --- | | @design-canvas/toolbox/core | packages/core/ | Runtime kernel, event bus, types, built-in service implementations | | @design-canvas/toolbox/loader | packages/loader/ | Config reader, plugin resolver, auto-init entry point | | @design-canvas/toolbox/loader/vite | packages/loader/src/vite.ts | Vite plugin for zero-config dev integration | | @design-canvas/toolbox/toolbar | packages/toolbar/ | Floating toolbar UI with plugin icon toggles | | @design-canvas/toolbox/panel | packages/panel/ | Draggable, dockable, resizable panel for plugin content | | @design-canvas/toolbox/server | packages/server/ | Dev server middleware (GitHub proxy, screenshots, notes sync, Copilot, checks) |


Built-in Plugins

| Plugin | ID | Description | | --- | --- | --- | | Annotations | annotate | Annotate page elements with notes, resolve/close workflow, severity levels, pin to selectors, file as GitHub issues (system — always visible) | | AI Settings | llm-settings | View/configure LLM providers, agent CLIs, edit modes, and install status (system — always active) |


Dev Server

The server package (@design-canvas/server) provides backend services that plugins rely on. When using the Vite plugin, it's embedded automatically. For standalone use:

npx design-canvas serve --port 4460

Server Routes

| Route | Purpose | | --- | --- | | /__dc/gh/* | GitHub API proxy (issues, users, repos) | | /__dc/notes | Notes CRUD + real-time sync (SSE) | | /__dc/screenshot | Playwright-based element screenshot capture | | /__dc/llm/* | LLM chat, status, and completions (multi-provider) | | /__dc/llm/pr/* | AI-powered PR generation (preview, commit, revert) | | /__dc/llm/delegate/* | Delegate tasks to GitHub Copilot Coding Agent | | /__dc/llm/agent/* | Local agent CLI execution (Copilot CLI, Claude Code, Codex) | | /__dc/llm/settings/* | LLM settings status and config updates | | /__dc/checks | Project check runner | | /__dc/skills | Skill file installer |

Environment Variables

| Variable | Purpose | | --- | --- | | GITHUB_TOKEN | GitHub Personal Access Token for issue/repo operations | | DC_REPO_OWNER | GitHub repository owner | | DC_REPO_NAME | GitHub repository name |


Config Reference

Full .designcanvas.json schema:

interface DesignCanvasConfig {
  $schema?: string;

  plugins?: Record<string, {
    enabled?: boolean;              // default: true
    config?: Record<string, unknown>; // plugin-specific config
  }>;

  toolbar?: {
    position?: 'left' | 'right' | 'top' | 'bottom';  // default: 'right'
    theme?: 'light' | 'dark' | 'auto';                // default: 'auto'
  };

  services?: Record<string, {
    provider?: string;
    [key: string]: unknown;
  }>;

  framework?: {
    id?: string;          // e.g. 'tailwind', 'bootstrap'
    configPath?: string;
  };

  design?: {
    source?: string;      // path to DESIGN.md or design token source
  };

  access?: {
    roles?: Record<string, string[]>;
  };
}

Example: Full Custom Plugin

Here's a complete plugin that uses multiple services:

import type { PluginDefinition, PluginContext } from '@design-canvas/toolbox/core';

export const accessibilityPlugin: PluginDefinition = {
  meta: {
    id: 'a11y-checker',
    name: 'Accessibility',
    icon: '♿',
    description: 'Check elements for accessibility issues',
    panel: {
      defaultSize: { width: 360, height: 480 },
      showStatusBar: true,
    },
  },

  activate(ctx: PluginContext) {
    let issues: Array<{ element: HTMLElement; message: string }> = [];

    // Register a keyboard shortcut
    ctx.services.commands.register('a11y:scan', 'Ctrl+Shift+A', () => scanPage());

    // Listen for element selections from other plugins
    ctx.on('element:selected', (event) => {
      const { selector } = event.payload as { selector: string };
      checkElement(document.querySelector(selector) as HTMLElement);
    });

    function scanPage() {
      issues = [];
      // Check all images for alt text
      document.querySelectorAll('img').forEach((img) => {
        if (!img.alt) {
          issues.push({ element: img, message: 'Missing alt attribute' });
        }
      });

      // Check buttons for accessible names
      document.querySelectorAll('button').forEach((btn) => {
        if (!btn.textContent?.trim() && !btn.getAttribute('aria-label')) {
          issues.push({ element: btn, message: 'Button has no accessible name' });
        }
      });

      // Update badge and status
      ctx.badge.set(issues.length);
      ctx.panel.setStatus(`${issues.length} issue${issues.length !== 1 ? 's' : ''}`);
      render();

      if (issues.length === 0) {
        ctx.services.notify.toast('No accessibility issues found!', 'success');
      }
    }

    function checkElement(el: HTMLElement) {
      if (!el) return;
      const tag = el.tagName.toLowerCase();
      if (tag === 'img' && !el.getAttribute('alt')) {
        ctx.services.notify.toast(`Image missing alt text`, 'warning');
      }
    }

    function render() {
      const dark = ctx.isDark;
      ctx.mountPoint.innerHTML = `
        <div style="padding: 16px; font-family: system-ui; color: ${dark ? '#e0e0e0' : '#1a1a1a'}">
          <button id="scan-btn" style="
            padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer;
            background: #0078d4; color: #fff; font-size: 13px; margin-bottom: 12px;
          ">Scan Page</button>
          ${issues.length === 0
            ? '<p style="color: #888; font-size: 13px;">Click "Scan Page" to check for issues.</p>'
            : issues.map((issue, i) => `
                <div style="
                  padding: 8px 12px; margin-bottom: 8px; border-radius: 6px;
                  background: ${dark ? 'rgba(255,107,107,0.1)' : 'rgba(209,52,56,0.06)'};
                  font-size: 13px; border-left: 3px solid #d13438;
                ">
                  <strong>${issue.element.tagName.toLowerCase()}</strong>: ${issue.message}
                </div>
              `).join('')
          }
        </div>
      `;
      ctx.mountPoint.querySelector('#scan-btn')?.addEventListener('click', scanPage);
    }

    render();

    // Set up status bar action
    ctx.panel.setStatusActions([
      { label: 'Scan', icon: '🔄', onClick: scanPage },
    ]);
  },

  deactivate() {
    // cleanup if needed
  },

  getAPI() {
    return {
      getIssueCount: () => 0,
    };
  },
};

Publishing to npm

The package is published automatically on every push to main via GitHub Actions — but only when the version in package.json changes. Pushes that don't bump the version are no-ops.

How it works

  1. The workflow runs npm cinpm run build
  2. Compares the local package.json version against the latest on npm
  3. Publishes with npm provenance for supply-chain transparency

Setup

The workflow requires an NPM_TOKEN secret in the repository:

  1. Create an Automation token at npmjs.com → Access Tokens
  2. Add it as a repo secret: Settings → Secrets and variables → Actions → New repository secret → name it NPM_TOKEN

Or via the CLI:

echo "<your-token>" | gh secret set NPM_TOKEN

Releasing a new version

npm version patch   # or minor / major
git push origin main

The workflow picks up the version bump and publishes automatically.


License

MIT