AgentSkillsCN

creating-widgets

从零开始创建新的 StickerNest 组件。适用于用户提出“创建组件”、“构建组件”、“新增组件”、“制作组件”或“实现组件功能”时使用。涵盖乐高积木式的组件模型、清单创建、数据绑定、主题层级、事件协议,以及组件注册。

SKILL.md
--- frontmatter
name: creating-widgets
description: Creating new StickerNest widgets from scratch. Use when the user asks to create a widget, build a widget, add a new widget, make a widget component, or implement widget functionality. Covers the Lego brick widget model, manifest creation, data binding, theme layers, event protocol, and widget registration.

Creating StickerNest Widgets

This skill guides you through creating widgets for StickerNest. Widgets follow the Lego Brick Model - they are composable, interchangeable units with standard connectors.

Core Philosophy: Widgets Are Lego Bricks

Widgets don't store data. Widgets display data.

A widget is a functional unit that:

  • Pulls data from any compatible source (native schemas, Notion, Obsidian, etc.)
  • Displays/edits that data according to its logic
  • Communicates via a standard event protocol
  • Can be themed/skinned without changing functionality
  • Is interchangeable with any widget of the same functional type

The Five Widget Layers

Every widget has these conceptual layers:

code
┌─────────────────────────────────────────────────────────────┐
│                      WIDGET LAYERS                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. THEME/SKIN LAYER                                        │
│     Visual appearance only. Uses CSS tokens.                │
│     Swappable without changing functionality.               │
│                                                              │
│  2. LOGIC LAYER                                             │
│     Core functionality. Hardcoded per widget type.          │
│     I/O definitions, event handling, capabilities.          │
│                                                              │
│  3. STATE LAYER                                             │
│     UI state only (not data). Filter, scroll, tab.          │
│     Persistence options: ephemeral/session/user/canvas.     │
│                                                              │
│  4. DATA BINDING LAYER                                      │
│     Connects to any compatible data source.                 │
│     Field mappings, filters, transforms.                    │
│                                                              │
│  5. PIPELINE I/O LAYER                                      │
│     Standard event protocol connectors.                     │
│     Inputs accepted, outputs produced.                      │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Widget Types

1. View Widgets (Display Data)

Connect to data sources and display them. Examples: Wiki View, Table View, Card Grid.

2. Interactive Widgets (User Input)

Handle user interactions and emit events. Examples: Button, Form, Slider.

3. Container Widgets (Group Others)

Host child widgets with hierarchy. Examples: Room, Panel, Group.

4. AI Widgets (Intelligent Agents)

Have personality, memory, and capabilities. See AI Widget section.

5. Bridge Widgets (External Data)

Connect to external services (Notion, Obsidian, etc.).


Quick Start: Creating an Inline Widget

Step 1: Create the Widget File

Create src/widgets/builtin/MyWidget.ts:

typescript
import type { WidgetManifest } from '../../types/manifest';
import type { BuiltinWidget } from './index';

export const MyWidgetManifest: WidgetManifest = {
  id: 'stickernest.my-widget',        // MUST be lowercase with hyphens
  name: 'My Widget',
  version: '1.0.0',
  kind: 'display',                     // See widget kinds below
  entry: 'index.html',
  description: 'Brief description for AI context',
  author: 'StickerNest',
  tags: ['category', 'feature'],

  // === DATA BINDING ===
  dataBinding: {
    // What shape of data can this widget display?
    requiredFields: [
      { role: 'title', types: ['text'], description: 'Main title' },
    ],
    optionalFields: [
      { role: 'image', types: ['asset_ref', 'url'], description: 'Preview image' },
      { role: 'tags', types: ['text[]'], description: 'Categorization' },
    ],
  },

  // === PIPELINE I/O ===
  inputs: {
    'data.set': { type: 'object', description: 'Set display data' },
    'filter.apply': { type: 'object', description: 'Apply filter' },
  },
  outputs: {
    'item.selected': { type: 'object', description: 'Item was selected' },
    'data.changed': { type: 'object', description: 'Data was modified' },
  },

  // === STATE PERSISTENCE ===
  state: {
    persistence: 'user',  // 'ephemeral' | 'session' | 'user' | 'canvas'
  },

  // === CAPABILITIES ===
  capabilities: {
    draggable: true,
    resizable: true,
    rotatable: true,
  },

  // === SIZE ===
  size: {
    width: 200,
    height: 150,
    minWidth: 100,
    minHeight: 80,
    scaleMode: 'scale',
  },
};

export const MyWidgetHTML = `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <style>
    /* === THEME LAYER: Use CSS tokens for all styling === */
    * { margin: 0; padding: 0; box-sizing: border-box; }
    html, body {
      width: 100%;
      height: 100%;
      overflow: hidden;
      font-family: var(--sn-font-family, -apple-system, sans-serif);
    }
    .container {
      width: 100%;
      height: 100%;
      display: flex;
      flex-direction: column;
      background: var(--sn-bg-primary, #0f0f19);
      color: var(--sn-text-primary, #e2e8f0);
      padding: var(--sn-spacing-md, 12px);
      border-radius: var(--sn-radius-md, 6px);
    }
    .title {
      font-size: var(--sn-font-size-lg, 1.125rem);
      font-weight: var(--sn-font-weight-bold, 600);
      color: var(--sn-text-primary, #e2e8f0);
    }
    .content {
      flex: 1;
      color: var(--sn-text-secondary, #94a3b8);
    }
  </style>
</head>
<body>
  <div class="container" id="container">
    <div class="title" id="title"></div>
    <div class="content" id="content"></div>
  </div>
  <script>
    (function() {
      const API = window.WidgetAPI;

      // === STATE LAYER ===
      let uiState = {
        filter: null,
        selectedItem: null,
      };

      // === DATA BINDING LAYER ===
      let boundData = null;
      let fieldMappings = {};

      // Initialize from saved state and data binding
      API.onMount(function(context) {
        // Restore UI state
        uiState = context.state || uiState;

        // Get data binding configuration
        fieldMappings = context.dataBinding?.mappings || {};

        // Initial render
        render();
        API.log('MyWidget mounted');
      });

      // Handle data binding updates
      API.onDataChange(function(data) {
        boundData = data;
        render();
        API.emitOutput('data.changed', { count: data?.length || 0 });
      });

      // Handle pipeline inputs
      API.onInput('filter.apply', function(filter) {
        uiState.filter = filter;
        API.setState({ filter });
        render();
      });

      // Handle UI state changes from external sources
      API.onStateChange(function(newState) {
        uiState = { ...uiState, ...newState };
        render();
      });

      // === LOGIC LAYER: Core widget functionality ===
      function render() {
        if (!boundData) return;

        // Map fields using the binding configuration
        const title = getField(boundData, fieldMappings.title);
        const content = getField(boundData, fieldMappings.content);

        document.getElementById('title').textContent = title || '';
        document.getElementById('content').textContent = content || '';
      }

      function getField(data, mapping) {
        if (!mapping || !data) return null;
        return data[mapping.sourceField];
      }

      // Handle user interactions
      document.getElementById('container').addEventListener('click', function(e) {
        if (uiState.selectedItem) {
          API.emitOutput('item.selected', { item: uiState.selectedItem });
        }
      });

      // Cleanup
      API.onDestroy(function() {
        API.log('MyWidget destroyed');
      });
    })();
  </script>
</body>
</html>
`;

export const MyWidget: BuiltinWidget = {
  manifest: MyWidgetManifest,
  html: MyWidgetHTML,
};

Step 2: Register the Widget

Add to src/widgets/builtin/index.ts:

typescript
import { MyWidget } from './MyWidget';

export const BUILTIN_WIDGETS: Record<string, BuiltinWidget> = {
  // ... existing widgets
  'stickernest.my-widget': MyWidget,
};

export { MyWidget };

Widget Manifest Reference

Required Fields

FieldTypeDescription
idstringUnique ID: namespace.widget-name (lowercase, hyphens)
namestringDisplay name for UI
versionstringSemantic version (X.Y.Z)
kindWidgetKindWidget category
entrystringEntry file, usually index.html
inputsRecordInput port definitions
outputsRecordOutput port definitions
capabilitiesobject{ draggable, resizable, rotatable }

Widget Classification (v3.1)

Widgets are classified using 3 orthogonal axes:

WidgetDomain (what the widget is FOR)

DomainUse Case
textText display, editing, formatting
mediaImages, video, audio players
timeTimers, clocks, countdowns
dataData transforms, charts, storage
layoutContainers, grids, organization
aiAI generation, pipelines, assistants
canvasCanvas tools, backgrounds, filters
socialSocial embeds, feeds, collaboration
spatialVR, AR, 3D positioning
gamesGame widgets, interactive entertainment

WidgetBadge (how the widget BEHAVES)

BadgeUse Case
inputAccepts data/signals from pipeline
outputEmits data/signals to pipeline
controlUser interaction (buttons, sliders)
displayPassive visualization
generatorCreates/generates content
systemInfrastructure/internal

WidgetTechnology (how the widget RENDERS)

TechnologyUse Case
2dStandard 2D DOM/CSS rendering
3dWebGL/Three.js 3D content
audioAudio-focused widget
videoVideo-focused widget
hybridMixed rendering modes

Deprecated: Widget Kinds

The following kinds are deprecated but still supported:

KindUse Case
display→ Use domain + badge: 'display'
interactive→ Use domain + badge: 'control'
container→ Use domain: 'layout'
view→ Use domain: 'data' + badge: 'display'
ai→ Use domain: 'ai'
bridge→ Use domain based on bridged service
2d→ Use technology: '2d'
3d→ Use technology: '3d'
audio→ Use technology: 'audio'
video→ Use technology: 'video'

Data Binding Configuration

typescript
dataBinding: {
  // Fields the widget REQUIRES
  requiredFields: [
    { role: 'title', types: ['text'], description: 'The main title' },
    { role: 'content', types: ['text', 'richtext'], description: 'Body content' },
  ],

  // Fields the widget CAN USE but doesn't require
  optionalFields: [
    { role: 'image', types: ['asset_ref', 'url'], description: 'Preview image' },
    { role: 'tags', types: ['text[]'], description: 'Tags for filtering' },
    { role: 'date', types: ['date', 'datetime'], description: 'Timestamp' },
  ],

  // What features this widget supports when bound to data
  features: ['search', 'filter', 'sort', 'edit_inline'],
}

State Persistence Configuration

typescript
state: {
  // Where UI state is saved
  persistence: 'user',  // Options:
  // 'ephemeral' - resets on page load
  // 'session'   - saved for this visit
  // 'user'      - saved per user (their preference)
  // 'canvas'    - saved for this canvas (all users see same)
}

WidgetAPI Reference

Lifecycle

javascript
API.onMount(callback)      // Called when widget loads
API.onDestroy(callback)    // Called before widget unloads

Data Binding

javascript
API.onDataChange(callback)         // Called when bound data changes
API.updateData(changes)            // Push changes back to data source
API.getFieldMapping(role)          // Get field mapping for a role

State Management

javascript
API.setState({ key: value })       // Save UI state
API.onStateChange(callback)        // Listen for state changes

Pipeline I/O

javascript
API.onInput('portId', callback)    // Listen for pipeline inputs
API.emitOutput('portId', data)     // Emit to pipeline outputs

Events (Broadcast)

javascript
API.emit('eventName', payload)     // Emit broadcast event
API.on('eventName', callback)      // Listen for broadcast events

Theme Tokens

Always use CSS variables for styling. This enables theme/skin swapping:

css
/* Backgrounds */
--sn-bg-primary: #0f0f19;
--sn-bg-secondary: #1a1a2e;
--sn-bg-tertiary: #252538;

/* Text */
--sn-text-primary: #e2e8f0;
--sn-text-secondary: #94a3b8;

/* Accents */
--sn-accent-primary: #8b5cf6;
--sn-success: #22c55e;
--sn-error: #ef4444;
--sn-warning: #f59e0b;

/* Spacing */
--sn-spacing-xs: 4px;
--sn-spacing-sm: 8px;
--sn-spacing-md: 12px;
--sn-spacing-lg: 16px;

/* Typography */
--sn-font-family: -apple-system, BlinkMacSystemFont, sans-serif;
--sn-font-size-sm: 0.875rem;
--sn-font-size-md: 1rem;
--sn-font-size-lg: 1.125rem;

/* Borders */
--sn-border-primary: rgba(139, 92, 246, 0.2);
--sn-radius-sm: 4px;
--sn-radius-md: 6px;
--sn-radius-lg: 8px;

Event Protocol (Standard Connectors)

All widgets must follow the event naming convention:

javascript
// Event format: domain:action or widget:id:action
"item:selected"         // Generic item selection
"filter:changed"        // Filter was modified
"data:updated"          // Data was updated
"widget:mywidget:ready" // Widget-specific event

// Standard event payloads include:
{
  source: 'widget-instance-id',
  timestamp: Date.now(),
  payload: { /* event-specific data */ }
}

Widget Interchangeability

Widgets of the same functional type should be interchangeable. To ensure this:

  1. Use standard event names - item:selected, not mywidget:itemClicked
  2. Declare data requirements - So any compatible data source works
  3. Use theme tokens - So appearance can be swapped via skins
  4. Keep logic generic - An "inventory widget" works with any item-shaped data

Validation Checklist

Before registering a new widget:

  • id is lowercase with hyphens (e.g., stickernest.my-widget)
  • version follows semver (X.Y.Z)
  • All required manifest fields present
  • dataBinding declares required and optional fields
  • Input/output ports use standard naming
  • All styling uses CSS variables (no hardcoded colors)
  • State persistence mode is specified
  • Uses window.WidgetAPI for all communication
  • Has onMount and onDestroy lifecycle handlers
  • Events follow naming conventions
  • Added to BUILTIN_WIDGETS registry

Reference Files

  • Vision document: docs/architecture/GAME_ENGINE_CANVAS_VISION.md
  • Manifest types: src/types/manifest.ts
  • Widget registry: src/widgets/builtin/index.ts
  • Protocol docs: Docs/WIDGET-PROTOCOL-SPEC.md
  • Theme tokens: See theming-with-tokens skill
  • Pipeline I/O: See connecting-widget-pipelines skill