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)
{
"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
<!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
<!-- 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
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
/* 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
// Host sends theme tokens on init and updates
WidgetAPI.applyTheme({
'widget-bg': '#1a1a2e',
'widget-text': '#ffffff',
'widget-accent': '#6366f1',
// ...
});
Widget Set Registration
// 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
// 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