# Building Social Widgets

> Building social widgets for StickerNest's default social experience. Use when the user asks to create feed widgets, chat widgets, notification widgets, profile widgets, friends list, or any social UI that plugs into the social layer. Covers widget-based social UI, Protocol integration, and theming.

- Skill: `majiayu000/building-social-widgets` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/building-social-widgets`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/building-social-widgets/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/building-social-widgets

---


# Building Social Widgets for StickerNest

This skill covers creating the default social widget set that provides the built-in social experience. All social UI is widget-based, making it customizable and replaceable.

## Philosophy

> "The built-in social experience is just a pre-installed widget set using the same Protocol as any custom widget."

- Default widgets prove the API works
- Users can replace/augment with custom widgets
- Everything is themeable via CSS variables
- Widgets connect to public and friends-only feeds

---

## Default Social Widget Set

| Widget ID | Purpose | Feed Type |
|-----------|---------|-----------|
| `social-feed-public` | Global activity feed | Public |
| `social-feed-friends` | Friends-only activity feed | Friends |
| `social-chat` | Canvas chat room | Canvas-scoped |
| `social-notifications` | Notification center | User-scoped |
| `social-profile-card` | User profile display | Per-user |
| `social-friends-list` | Online friends list | User-scoped |
| `social-who-online` | Canvas presence | Canvas-scoped |
| `social-dm-inbox` | Direct messages | User-scoped |

---

## Widget Manifest Template (Social)

```json
{
  "id": "social-feed-public",
  "name": "Public Feed",
  "version": "1.0.0",
  "description": "Shows public activity from all users",
  "kind": "2d",
  "entry": "index.html",
  "category": "social",
  "tags": ["social", "feed", "activity"],
  "author": "StickerNest",
  "capabilities": {
    "draggable": true,
    "resizable": true,
    "rotatable": false
  },
  "defaultSize": {
    "width": 320,
    "height": 480
  },
  "permissions": [
    "social:read",
    "social:subscribe"
  ],
  "inputs": {
    "feedType": {
      "type": "string",
      "description": "Feed type: 'public' or 'friends'",
      "default": "public"
    },
    "limit": {
      "type": "number",
      "description": "Number of items to show",
      "default": 20
    }
  },
  "outputs": {
    "activitySelected": {
      "type": "object",
      "description": "When user clicks an activity"
    }
  }
}
```

---

## Social Widget HTML Template

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Social Feed</title>
  <style>
    /* Theme tokens - inherited from host */
    :root {
      --social-bg: var(--widget-bg, #1a1a2e);
      --social-text: var(--widget-text, #eee);
      --social-text-muted: var(--widget-text-muted, #888);
      --social-border: var(--widget-border, #333);
      --social-accent: var(--widget-accent, #6366f1);
      --social-item-bg: var(--widget-item-bg, #252542);
      --social-item-hover: var(--widget-item-hover, #2d2d4a);
      --social-avatar-size: 40px;
      --social-spacing: 12px;
    }

    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      font-family: system-ui, -apple-system, sans-serif;
      background: var(--social-bg);
      color: var(--social-text);
      height: 100vh;
      overflow: hidden;
      display: flex;
      flex-direction: column;
    }

    .header {
      padding: var(--social-spacing);
      border-bottom: 1px solid var(--social-border);
      display: flex;
      align-items: center;
      justify-content: space-between;
    }

    .header h2 {
      font-size: 14px;
      font-weight: 600;
    }

    .feed {
      flex: 1;
      overflow-y: auto;
      padding: var(--social-spacing);
    }

    .feed-item {
      display: flex;
      gap: var(--social-spacing);
      padding: var(--social-spacing);
      background: var(--social-item-bg);
      border-radius: 8px;
      margin-bottom: 8px;
      cursor: pointer;
      transition: background 0.15s;
    }

    .feed-item:hover {
      background: var(--social-item-hover);
    }

    .avatar {
      width: var(--social-avatar-size);
      height: var(--social-avatar-size);
      border-radius: 50%;
      background: var(--social-accent);
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      font-weight: bold;
      font-size: 14px;
    }

    .avatar img {
      width: 100%;
      height: 100%;
      border-radius: 50%;
      object-fit: cover;
    }

    .content {
      flex: 1;
      min-width: 0;
    }

    .username {
      font-weight: 600;
      font-size: 13px;
    }

    .action {
      font-size: 13px;
      color: var(--social-text-muted);
    }

    .action .verb {
      color: var(--social-accent);
    }

    .timestamp {
      font-size: 11px;
      color: var(--social-text-muted);
      margin-top: 4px;
    }

    .empty-state {
      text-align: center;
      padding: 40px 20px;
      color: var(--social-text-muted);
    }

    .loading {
      text-align: center;
      padding: 20px;
      color: var(--social-text-muted);
    }
  </style>
</head>
<body>
  <div class="header">
    <h2 id="title">Public Feed</h2>
    <button id="refresh" style="background: none; border: none; color: var(--social-accent); cursor: pointer;">
      Refresh
    </button>
  </div>

  <div class="feed" id="feed">
    <div class="loading">Loading...</div>
  </div>

  <script>
    // Widget Protocol v3.0 implementation
    const WidgetAPI = {
      ready: false,
      instanceId: null,
      config: {},

      init() {
        window.addEventListener('message', this.handleMessage.bind(this));
        this.send('widget:ready');
      },

      send(type, payload = {}) {
        window.parent.postMessage({ type, payload }, '*');
      },

      handleMessage(event) {
        const { type, payload } = event.data || {};

        switch (type) {
          case 'widget:init':
            this.instanceId = payload.instanceId;
            this.config = payload.config || {};
            this.ready = true;
            this.onReady();
            break;

          case 'widget:event':
            this.onEvent(payload.type, payload.payload);
            break;

          case 'widget:theme':
            this.applyTheme(payload);
            break;
        }
      },

      onReady() {
        // Override in widget
      },

      onEvent(eventType, payload) {
        // Override in widget
      },

      emit(outputName, data) {
        this.send('widget:emit', { type: outputName, payload: data });
      },

      request(action, data) {
        return new Promise((resolve) => {
          const requestId = Date.now().toString();
          const handler = (event) => {
            if (event.data?.type === 'widget:response' &&
                event.data?.payload?.requestId === requestId) {
              window.removeEventListener('message', handler);
              resolve(event.data.payload.result);
            }
          };
          window.addEventListener('message', handler);
          this.send('widget:request', { action, data, requestId });
        });
      },

      applyTheme(tokens) {
        const root = document.documentElement;
        Object.entries(tokens).forEach(([key, value]) => {
          root.style.setProperty(`--${key}`, value);
        });
      },

      // Social-specific helpers
      async getFeed(type = 'public', limit = 20) {
        return this.request('social:getFeed', { type, limit });
      },

      async follow(userId) {
        return this.request('social:follow', { userId });
      },

      async unfollow(userId) {
        return this.request('social:unfollow', { userId });
      },

      subscribeTo(eventName, callback) {
        this._eventHandlers = this._eventHandlers || {};
        this._eventHandlers[eventName] = callback;
      }
    };

    // Feed Widget Implementation
    class FeedWidget {
      constructor() {
        this.feedEl = document.getElementById('feed');
        this.titleEl = document.getElementById('title');
        this.refreshBtn = document.getElementById('refresh');
        this.feedType = 'public';
        this.activities = [];

        this.refreshBtn.addEventListener('click', () => this.loadFeed());
      }

      async init(config) {
        this.feedType = config.feedType || 'public';
        this.titleEl.textContent = this.feedType === 'friends'
          ? 'Friends Feed'
          : 'Public Feed';

        await this.loadFeed();

        // Subscribe to live updates
        WidgetAPI.subscribeTo('social:feed-update', (activity) => {
          this.addActivity(activity, true);
        });
      }

      async loadFeed() {
        this.feedEl.innerHTML = '<div class="loading">Loading...</div>';

        try {
          const result = await WidgetAPI.getFeed(this.feedType, 20);
          this.activities = result.activities || [];
          this.render();
        } catch (err) {
          this.feedEl.innerHTML = `
            <div class="empty-state">
              Failed to load feed. <a href="#" onclick="feedWidget.loadFeed()">Retry</a>
            </div>
          `;
        }
      }

      addActivity(activity, prepend = false) {
        if (prepend) {
          this.activities.unshift(activity);
        } else {
          this.activities.push(activity);
        }
        this.render();
      }

      render() {
        if (this.activities.length === 0) {
          this.feedEl.innerHTML = `
            <div class="empty-state">
              No activities yet.<br>
              Follow some users to see their updates!
            </div>
          `;
          return;
        }

        this.feedEl.innerHTML = this.activities.map(activity => `
          <div class="feed-item" data-id="${activity.id}">
            <div class="avatar">
              ${activity.actor?.avatar_url
                ? `<img src="${activity.actor.avatar_url}" alt="">`
                : activity.actor?.display_name?.[0] || '?'}
            </div>
            <div class="content">
              <div class="action">
                <span class="username">${activity.actor?.display_name || 'Unknown'}</span>
                <span class="verb">${this.formatVerb(activity.verb)}</span>
                ${activity.metadata?.title || activity.object_type}
              </div>
              <div class="timestamp">${this.formatTime(activity.created_at)}</div>
            </div>
          </div>
        `).join('');

        // Add click handlers
        this.feedEl.querySelectorAll('.feed-item').forEach(item => {
          item.addEventListener('click', () => {
            const id = item.dataset.id;
            const activity = this.activities.find(a => a.id === id);
            if (activity) {
              WidgetAPI.emit('activitySelected', activity);
            }
          });
        });
      }

      formatVerb(verb) {
        const verbs = {
          published: 'published',
          forked: 'forked',
          liked: 'liked',
          commented: 'commented on',
          followed: 'followed',
        };
        return verbs[verb] || verb;
      }

      formatTime(timestamp) {
        const date = new Date(timestamp);
        const now = new Date();
        const diff = now - date;

        if (diff < 60000) return 'just now';
        if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
        if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
        return date.toLocaleDateString();
      }
    }

    // Initialize
    const feedWidget = new FeedWidget();

    WidgetAPI.onReady = () => {
      feedWidget.init(WidgetAPI.config);
    };

    WidgetAPI.onEvent = (type, payload) => {
      if (WidgetAPI._eventHandlers?.[type]) {
        WidgetAPI._eventHandlers[type](payload);
      }
    };

    WidgetAPI.init();
  </script>
</body>
</html>
```

---

## Social Protocol Messages

### Widget → Host Requests

| Action | Payload | Response |
|--------|---------|----------|
| `social:getFeed` | `{ type, limit, offset }` | `{ activities: [...] }` |
| `social:getProfile` | `{ userId }` | `{ profile: {...} }` |
| `social:follow` | `{ userId }` | `{ success: boolean }` |
| `social:unfollow` | `{ userId }` | `{ success: boolean }` |
| `social:sendMessage` | `{ channelId, content }` | `{ message: {...} }` |
| `social:getMessages` | `{ channelId, limit }` | `{ messages: [...] }` |
| `social:getNotifications` | `{ limit }` | `{ notifications: [...] }` |
| `social:markRead` | `{ notificationId }` | `{ success: boolean }` |
| `social:getOnlineUsers` | `{ canvasId }` | `{ users: [...] }` |

### Host → Widget Events

| Event | Payload | When |
|-------|---------|------|
| `social:feed-update` | `{ activity }` | New activity posted |
| `social:message-new` | `{ message }` | New chat message |
| `social:notification-new` | `{ notification }` | New notification |
| `social:presence-update` | `{ userId, status }` | User online/offline |
| `social:typing` | `{ userId, isTyping }` | Typing indicator |

---

## Chat Widget Example

```html
<!-- Simplified for brevity -->
<script>
class ChatWidget {
  constructor() {
    this.messages = [];
    this.channelId = null;
  }

  async init(config) {
    this.channelId = config.channelId || 'global';
    await this.loadMessages();

    // Subscribe to new messages
    WidgetAPI.subscribeTo('social:message-new', (msg) => {
      if (msg.channelId === this.channelId) {
        this.addMessage(msg);
      }
    });

    // Subscribe to typing indicators
    WidgetAPI.subscribeTo('social:typing', (data) => {
      this.showTypingIndicator(data.userId, data.isTyping);
    });
  }

  async sendMessage(content) {
    const result = await WidgetAPI.request('social:sendMessage', {
      channelId: this.channelId,
      content,
    });
    // Optimistic update already handled, realtime confirms
  }

  // ... render methods
}
</script>
```

---

## Notification Widget Example

```javascript
class NotificationWidget {
  async init() {
    await this.loadNotifications();

    WidgetAPI.subscribeTo('social:notification-new', (notif) => {
      this.addNotification(notif, true);
      this.playSound();
      this.showBadge();
    });
  }

  async markAsRead(notificationId) {
    await WidgetAPI.request('social:markRead', { notificationId });
    this.updateNotification(notificationId, { read: true });
  }

  async markAllRead() {
    await WidgetAPI.request('social:markAllRead', {});
    this.notifications.forEach(n => n.read = true);
    this.render();
  }
}
```

---

## Theming Social Widgets

### Required CSS Variables

```css
/* Base widget tokens (inherited from canvas theme) */
--widget-bg
--widget-text
--widget-text-muted
--widget-border
--widget-accent
--widget-item-bg
--widget-item-hover

/* Social-specific tokens */
--social-avatar-size: 40px;
--social-avatar-border: 2px solid var(--widget-accent);
--social-online-color: #22c55e;
--social-offline-color: #6b7280;
--social-unread-bg: rgba(99, 102, 241, 0.1);
--social-typing-color: var(--widget-text-muted);
--social-chat-bubble-self: var(--widget-accent);
--social-chat-bubble-other: var(--widget-item-bg);
```

### Theme Inheritance

```javascript
// Host sends theme tokens on init and updates
WidgetAPI.applyTheme({
  'widget-bg': '#1a1a2e',
  'widget-text': '#ffffff',
  'widget-accent': '#6366f1',
  // ...
});
```

---

## Widget Set Registration

```typescript
// src/widgets/builtin/social/index.ts

export const socialWidgetSet = {
  id: 'social-default',
  name: 'StickerNest Social',
  description: 'Default social widgets for feeds, chat, and notifications',
  widgets: [
    {
      id: 'social-feed-public',
      path: '/widgets/social/feed-public/',
    },
    {
      id: 'social-feed-friends',
      path: '/widgets/social/feed-friends/',
    },
    {
      id: 'social-chat',
      path: '/widgets/social/chat/',
    },
    {
      id: 'social-notifications',
      path: '/widgets/social/notifications/',
    },
    {
      id: 'social-profile-card',
      path: '/widgets/social/profile-card/',
    },
    {
      id: 'social-friends-list',
      path: '/widgets/social/friends-list/',
    },
    {
      id: 'social-who-online',
      path: '/widgets/social/who-online/',
    },
    {
      id: 'social-dm-inbox',
      path: '/widgets/social/dm-inbox/',
    },
  ],
};
```

---

## Testing Social Widgets

```typescript
// tests/social-widgets.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Social Feed Widget', () => {
  test('loads public feed', async ({ page }) => {
    await page.goto('/canvas/test');

    // Add feed widget
    await page.click('[data-widget="social-feed-public"]');

    // Wait for feed to load
    await expect(page.locator('.feed-item')).toBeVisible();
  });

  test('receives realtime updates', async ({ page }) => {
    // Setup feed widget
    await page.goto('/canvas/test');

    // Post activity from another context
    await postTestActivity();

    // Verify feed updates
    await expect(page.locator('.feed-item').first())
      .toContainText('published');
  });
});
```

---

## Reference Files

| File | Purpose |
|------|---------|
| `public/test-widgets/activity-feed/` | Existing feed widget |
| `public/test-widgets/chat-room/` | Existing chat widget |
| `public/test-widgets/notification-center/` | Existing notification widget |
| `src/widgets/builtin/social/` | Built-in social widgets |
| `src/runtime/WidgetHost.ts` | Widget sandbox host |
| `src/services/social/` | Social backend services |

---

## Best Practices

1. **Always use Protocol requests** - Never call services directly
2. **Subscribe to realtime events** - Keep UI in sync
3. **Theme with CSS variables** - Inherit from host theme
4. **Handle empty states** - Show helpful messages
5. **Optimistic updates** - Instant feedback before server confirms
6. **Accessible markup** - Proper ARIA labels, keyboard nav
7. **Mobile-friendly** - Touch targets, responsive layout
8. **Error boundaries** - Graceful degradation on failures

