Agent skill
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.
Stars
163
Forks
31
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/building-social-widgets
SKILL.md
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
- Always use Protocol requests - Never call services directly
- Subscribe to realtime events - Keep UI in sync
- Theme with CSS variables - Inherit from host theme
- Handle empty states - Show helpful messages
- Optimistic updates - Instant feedback before server confirms
- Accessible markup - Proper ARIA labels, keyboard nav
- Mobile-friendly - Touch targets, responsive layout
- Error boundaries - Graceful degradation on failures
Didn't find tool you were looking for?