0
ART/VISUAL#b5cff2bc
Live Room Whiteboard
@Owner·deposited 2w ago·updated 2w ago·67 views
ART/VISUAL#b5cff2bc
Live Room Whiteboard
OW
@Owner
67Views
0Comments
0Forks
0Saves
SHARE · REMIX
Live Room Whiteboard — a JSX Art/Visual widget by @Owner.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widgetNo Vibes SDK features detected yet
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "Live Room Whiteboard" by @Owner]
Source: https://itjustvibes.com/Owner/live-room-whiteboard
Type: React/JSX
--- SOURCE CODE ---
```jsx
function Widget() {
const STORE_KEY = 'liveRoomWhiteboard:v1';
const DEFAULT_ROOM = 'studio';
const BOARD_WIDTH = 1000;
const BOARD_HEIGHT = 620;
const MAX_STROKES = 72;
const MAX_POINTS = 48;
const MAX_CURSORS = 6;
const CURSOR_TTL = 6000;
const PALETTE = [
{ id: 'ink', color: '#1f2937', label: 'Ink' },
{ id: 'blue', color: '#2563eb', label: 'Blue' },
{ id: 'coral', color: '#ea580c', label: 'Coral' },
{ id: 'mint', color: '#059669', label: 'Mint' },
];
const memoryRoomRef = React.useRef(createRoomState(DEFAULT_ROOM));
const sharedRoomRef = React.useRef(memoryRoomRef.current);
const profileRef = React.useRef(createProfile({}));
const draftStrokeRef = React.useRef(null);
const lastCursorPublishRef = React.useRef(0);
const [ready, setReady] = React.useState(false);
const [profile, setProfile] = React.useState(profileRef.current);
const [roomInput, setRoomInput] = React.useState(DEFAULT_ROOM);
const [roomState, setRoomState] = React.useState(memoryRoomRef.current);
const [notice, setNotice] = React.useState('Loading board...');
function normalizeRoomId(value) {
const cleaned = String(value || '')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 18);
return cleaned || DEFAULT_ROOM;
}
function randomId(prefix) {
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}
function createProfile(saved) {
const color = PALETTE.some((item) => item.color === saved?.color) ? saved.color : PALETTE[0].color;
const tool = saved?.tool === 'erase' ? 'erase' : 'pen';
return {
displayName:
typeof saved?.displayName === 'string' && saved.displayName.trim()
? saved.displayName.trim().slice(0, 22)
: 'Marker Friend',
userId:
typeof saved?.userId === 'string' && saved.userId
? saved.userId.slice(0, 40)
: randomId('drawer'),
roomId: normalizeRoomId(saved?.roomId || DEFAULT_ROOM),
color,
tool,
};
}
function createRoomState(roomId) {
return {
roomId: normalizeRoomId(roomId),
revision: Date.now(),
strokes: [],
cursors: {},
clearedAt: 0,
updatedAt: Date.now(),
};
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function normalizePoint(x, y) {
return [
Math.round(clamp(x, 0, 1) * BOARD_WIDTH),
Math.round(clamp(y, 0, 1) * BOARD_HEIGHT),
];
}
function formatPath(points) {
if (!points.length) return '';
return points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point[0]} ${point[1]}`)
.join(' ');
}
function pointsEqual(a, b) {
return !!a && !!b && a[0] === b[0] && a[1] === b[1];
}
function appendPoint(points, nextPoint) {
const last = points[points.length - 1];
if (last && pointsEqual(last, nextPoint)) {
return points;
}
return [...points, nextPoint].slice(-MAX_POINTS);
}
function mergeStrokes(base, incoming) {
const merged = new Map();
[...(base || []), ...(incoming || [])].forEach((stroke) => {
if (!stroke || typeof stroke.id !== 'string') return;
merged.set(stroke.id, sanitizeStroke(stroke));
});
return [...merged.values()]
.sort((a, b) => a.createdAt - b.createdAt)
.slice(-MAX_STROKES);
}
function sanitizeStroke(stroke) {
const tool = stroke?.tool === 'erase' ? 'erase' : 'pen';
const color = PALETTE.some((item) => item.color === stroke?.color) ? stroke.color : PALETTE[0].color;
const size = clamp(Number(stroke?.size) || (tool === 'erase' ? 26 : 4), 2, 36);
const points = Array.isArray(stroke?.points)
? stroke.points
.filter((point) => Array.isArray(point) && point.length === 2)
.map((point) => [
clamp(Math.round(Number(point[0]) || 0), 0, BOARD_WIDTH),
clamp(Math.round(Number(point[1]) || 0), 0, BOARD_HEIGHT),
])
.slice(0, MAX_POINTS)
: [];
return {
id: typeof stroke?.id === 'string' ? stroke.id.slice(0, 64) : randomId('stroke'),
userId: typeof stroke?.userId === 'string' ? stroke.userId.slice(0, 40) : 'guest',
color,
tool,
size,
points,
createdAt: Number(stroke?.createdAt) || Date.now(),
};
}
function sanitizeCursors(nextCursors) {
const now = Date.now();
const entries = Object.entries(nextCursors || {})
.filter(([, cursor]) => cursor && typeof cursor === 'object')
.map(([userId, cursor]) => [
userId.slice(0, 40),
{
name:
typeof cursor.name === 'string' && cursor.name.trim()
? cursor.name.trim().slice(0, 22)
: 'Guest',
color: PALETTE.some((item) => item.color === cursor.color) ? cursor.color : PALETTE[0].color,
x: clamp(Math.round(Number(cursor.x) || 0), 0, BOARD_WIDTH),
y: clamp(Math.round(Number(cursor.y) || 0), 0, BOARD_HEIGHT),
activeAt: Number(cursor.activeAt) || now,
},
])
.filter(([, cursor]) => now - cursor.activeAt < CURSOR_TTL)
.sort((a, b) => b[1].activeAt - a[1].activeAt)
.slice(0, MAX_CURSORS);
return Object.fromEntries(entries);
}
function sanitizeRoomState(nextState, fallbackRoomId) {
const roomId = normalizeRoomId(nextState?.roomId || fallbackRoomId || DEFAULT_ROOM);
const strokes = Array.isArray(nextState?.strokes)
? nextState.strokes.map(sanitizeStroke).filter((stroke) => stroke.points.length > 0).slice(-MAX_STROKES)
: [];
return {
roomId,
revision: Number(nextState?.revision) || Date.now(),
strokes,
cursors: sanitizeCursors(nextState?.cursors),
clearedAt: Number(nextState?.clearedAt) || 0,
updatedAt: Number(nextState?.updatedAt) || Date.now(),
};
}
function buildRoomUpdate(patch) {
const base = sharedRoomRef.current && sharedRoomRef.current.roomId === profileRef.current.roomId
? sharedRoomRef.current
: memoryRoomRef.current;
const nextState = sanitizeRoomState(
{
...base,
...patch,
roomId: profileRef.current.roomId,
strokes: patch?.strokes ? mergeStrokes(base.strokes, patch.strokes) : base.strokes,
cursors: patch?.cursors ? { ...base.cursors, ...patch.cursors } : base.cursors,
revision: Date.now(),
updatedAt: Date.now(),
},
profileRef.current.roomId
);
return nextState;
}
function publishRoom(nextState) {
const safeState = sanitizeRoomState(nextState, profileRef.current.roomId);
sharedRoomRef.current = safeState;
memoryRoomRef.current = safeState;
setRoomState(safeState);
const api = window.jankbank;
if (api && api.shared && typeof api.shared.set === 'function') {
api.shared.set(safeState);
}
}
async function saveProfile(nextProfile) {
const safeProfile = createProfile(nextProfile);
profileRef.current = safeProfile;
setProfile(safeProfile);
setRoomInput(safeProfile.roomId);
const api = window.jankbank;
if (api && typeof api.save === 'function') {
await api.save(STORE_KEY, safeProfile);
}
}
function getPointerPoint(event) {
const svg = event.currentTarget;
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return [0, 0];
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
return normalizePoint(x, y);
}
function updateCursor(point) {
const now = Date.now();
if (now - lastCursorPublishRef.current < 40) return;
lastCursorPublishRef.current = now;
const nextRoom = buildRoomUpdate({
cursors: {
[profile.userId]: {
name: profile.displayName,
color: profile.color,
x: point[0],
y: point[1],
activeAt: now,
},
},
});
publishRoom(nextRoom);
}
function handlePointerDown(event) {
const point = getPointerPoint(event);
draftStrokeRef.current = {
id: randomId('stroke'),
userId: profile.userId,
tool: profile.tool,
color: profile.color,
size: profile.tool === 'erase' ? 26 : 4,
points: [point],
createdAt: Date.now(),
};
updateCursor(point);
}
function handlePointerMove(event) {
const point = getPointerPoint(event);
updateCursor(point);
const draft = draftStrokeRef.current;
if (!draft) return;
draft.points = appendPoint(draft.points, point);
draftStrokeRef.current = draft;
setRoomState((current) => ({
...current,
cursors: {
...current.cursors,
[profile.userId]: {
name: profile.displayName,
color: profile.color,
x: point[0],
y: point[1],
activeAt: Date.now(),
},
},
}));
}
function finishStroke() {
const draft = draftStrokeRef.current;
draftStrokeRef.current = null;
if (!draft || draft.points.length === 0) return;
const nextRoom = buildRoomUpdate({
strokes: [draft],
cursors: {
[profile.userId]: {
name: profile.displayName,
color: profile.color,
x: draft.points[draft.points.length - 1][0],
y: draft.points[draft.points.length - 1][1],
activeAt: Date.now(),
},
},
});
publishRoom(nextRoom);
}
async function joinRoom(nextRoomId) {
const normalized = normalizeRoomId(nextRoomId);
await saveProfile({
...profileRef.current,
roomId: normalized,
});
const candidate =
sharedRoomRef.current && sharedRoomRef.current.roomId === normalized
? sharedRoomRef.current
: createRoomState(normalized);
publishRoom(candidate);
setNotice(`Joined ${normalized}.`);
}
async function setDisplayName(value) {
await saveProfile({
...profileRef.current,
displayName: value.slice(0, 22),
});
}
async function setTool(tool) {
await saveProfile({
...profileRef.current,
tool,
});
}
async function setColor(color) {
await saveProfile({
...profileRef.current,
color,
tool: 'pen',
});
}
function clearBoard() {
const nextRoom = sanitizeRoomState(
{
roomId: profile.roomId,
revision: Date.now(),
strokes: [],
cursors: {
...roomState.cursors,
[profile.userId]: {
name: profile.displayName,
color: profile.color,
x: 120,
y: 120,
activeAt: Date.now(),
},
},
clearedAt: Date.now(),
updatedAt: Date.now(),
},
profile.roomId
);
publishRoom(nextRoom);
setNotice('Board cleared for a fresh sketch.');
}
React.useEffect(() => {
profileRef.current = profile;
}, [profile]);
React.useEffect(() => {
let cancelled = false;
const initialRoom = createRoomState(DEFAULT_ROOM);
memoryRoomRef.current = initialRoom;
sharedRoomRef.current = initialRoom;
setRoomState(initialRoom);
const api = window.jankbank;
const shared = api && api.shared ? api.shared : null;
if (shared && typeof shared.onChange === 'function') {
shared.onChange((nextState) => {
if (cancelled || !nextState) return;
const safeState = sanitizeRoomState(nextState, profileRef.current.roomId);
sharedRoomRef.current = safeState;
if (safeState.roomId === profileRef.current.roomId) {
setRoomState(safeState);
}
});
}
if (!api || typeof api.onReady !== 'function') {
setReady(true);
setNotice('Local demo mode active. Shared sync unavailable.');
return () => {
cancelled = true;
};
}
api.onReady(async () => {
const saved = await api.load(STORE_KEY);
if (cancelled) return;
const nextProfile = createProfile(saved || {});
profileRef.current = nextProfile;
setProfile(nextProfile);
setRoomInput(nextProfile.roomId);
const nextRoom =
sharedRoomRef.current && sharedRoomRef.current.roomId === nextProfile.roomId
? sharedRoomRef.current
: createRoomState(nextProfile.roomId);
memoryRoomRef.current = nextRoom;
sharedRoomRef.current = nextRoom;
setRoomState(nextRoom);
setReady(true);
setNotice(
shared && typeof shared.set === 'function'
? `Shared room sync ready in ${nextProfile.roomId}.`
: 'Profile restored. Shared sync unavailable, using local mode.'
);
});
return () => {
cancelled = true;
};
}, []);
React.useEffect(() => {
if (!ready) return;
if (roomState.roomId === profile.roomId) return;
const nextRoom =
sharedRoomRef.current && sharedRoomRef.current.roomId === profile.roomId
? sharedRoomRef.current
: createRoomState(profile.roomId);
publishRoom(nextRoom);
}, [profile.roomId, ready]);
const previewStroke = draftStrokeRef.current;
const renderedStrokes = previewStroke ? [...roomState.strokes, previewStroke] : roomState.strokes;
const visibleCursors = Object.entries(roomState.cursors || {})
.filter(([, cursor]) => Date.now() - cursor.activeAt < CURSOR_TTL)
.slice(0, MAX_CURSORS);
const sharedEnabled = !!(
window.jankbank &&
window.jankbank.shared &&
typeof window.jankbank.shared.set === 'function'
);
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.16),_transparent_32%),linear-gradient(180deg,#0f172a_0%,#172554_44%,#111827_100%)] p-4 text-slate-100">
<section className="mx-auto flex max-w-md flex-col gap-4 rounded-[28px] border border-white/10 bg-slate-950/55 p-4 shadow-[0_24px_70px_rgba(15,23,42,0.48)] backdrop-blur">
<header className="rounded-[26px] border border-sky-200/15 bg-[linear-gradient(135deg,rgba(250,245,235,0.96),rgba(224,242,254,0.88),rgba(253,230,138,0.84))] p-4 text-slate-900">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.32em] text-sky-700">Shared Sketch Room</p>
<h1 className="text-3xl font-black tracking-tight">Live Room Whiteboard</h1>
<p className="mt-1 max-w-xs text-sm text-slate-700">
Tiny same-room drawing with live cursors, quick erasing, and no setup ceremony.
</p>
</div>
<div className="rounded-2xl border border-slate-900/10 bg-white/70 px-3 py-2 text-right">
<p className="text-[10px] uppercase tracking-[0.24em] text-slate-500">Mode</p>
<p className="text-sm font-black text-slate-900">{sharedEnabled ? 'Shared' : 'Local'}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-2xl bg-slate-900/6 p-3">
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-500">Room</p>
<p className="mt-1 text-sm font-black">{profile.roomId}</p>
</div>
<div className="rounded-2xl bg-slate-900/6 p-3">
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-500">Strokes</p>
<p className="mt-1 text-sm font-black">{roomState.strokes.length}</p>
</div>
<div className="rounded-2xl bg-slate-900/6 p-3">
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-500">People</p>
<p className="mt-1 text-sm font-black">{visibleCursors.length || 1}</p>
</div>
</div>
</header>
<section className="grid grid-cols-2 gap-3">
<label className="col-span-2 flex flex-col gap-2 rounded-2xl border border-white/10 bg-white/5 p-3">
<span className="text-[11px] font-bold uppercase tracking-[0.2em] text-slate-400">Display Name</span>
<input
value={profile.displayName}
onChange={(event) => setDisplayName(event.target.value)}
className="rounded-xl border border-white/10 bg-slate-950/50 px-3 py-2 text-sm text-white outline-none focus:border-sky-300"
placeholder="Marker Friend"
/>
</label>
<label className="col-span-2 flex flex-col gap-2 rounded-2xl border border-white/10 bg-white/5 p-3">
<span className="text-[11px] font-bold uppercase tracking-[0.2em] text-slate-400">Room Code</span>
<div className="flex gap-2">
<input
value={roomInput}
onChange={(event) => setRoomInput(normalizeRoomId(event.target.value))}
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-slate-950/50 px-3 py-2 text-sm text-white outline-none focus:border-amber-300"
placeholder="studio"
/>
<button
type="button"
onClick={() => joinRoom(roomInput)}
className="rounded-xl bg-amber-300 px-3 py-2 text-xs font-black uppercase tracking-[0.16em] text-slate-950"
>
Join
</button>
</div>
</label>
</section>
<section className="rounded-[26px] border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setTool('pen')}
className={`rounded-full px-3 py-2 text-xs font-black uppercase tracking-[0.14em] ${
profile.tool === 'pen' ? 'bg-sky-300 text-slate-950' : 'bg-white/8 text-white'
}`}
>
Pen
</button>
<button
type="button"
onClick={() => setTool('erase')}
className={`rounded-full px-3 py-2 text-xs font-black uppercase tracking-[0.14em] ${
profile.tool === 'erase' ? 'bg-rose-300 text-slate-950' : 'bg-white/8 text-white'
}`}
>
Eraser
</button>
<button
type="button"
onClick={clearBoard}
className="rounded-full bg-white/8 px-3 py-2 text-xs font-black uppercase tracking-[0.14em] text-white"
>
Clear Board
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{PALETTE.map((swatch) => (
<button
key={swatch.id}
type="button"
onClick={() => setColor(swatch.color)}
className={`flex items-center gap-2 rounded-full border px-3 py-2 text-xs font-bold ${
profile.color === swatch.color && profile.tool === 'pen'
? 'border-white bg-white/12 text-white'
: 'border-white/10 bg-slate-950/40 text-slate-300'
}`}
>
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: swatch.color }} />
{swatch.label}
</button>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-[#f9f7ef] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]">
<div className="rounded-[22px] border border-slate-900/8 bg-white/70 p-2">
<svg
viewBox={`0 0 ${BOARD_WIDTH} ${BOARD_HEIGHT}`}
className="aspect-[10/6] w-full touch-none overflow-hidden rounded-[18px] bg-[linear-gradient(180deg,#fffdf8_0%,#f4f1e6_100%)]"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishStroke}
onPointerLeave={finishStroke}
>
<defs>
<pattern id="paper-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(148,163,184,0.18)" strokeWidth="1" />
</pattern>
</defs>
<rect x="0" y="0" width={BOARD_WIDTH} height={BOARD_HEIGHT} fill="url(#paper-grid)" />
{renderedStrokes.map((stroke) => (
<path
key={stroke.id}
d={formatPath(stroke.points)}
fill="none"
stroke={stroke.tool === 'erase' ? '#f9f7ef' : stroke.color}
strokeWidth={stroke.size}
strokeLinecap="round"
strokeLinejoin="round"
opacity={stroke.tool === 'erase' ? 0.96 : 0.92}
/>
))}
{visibleCursors.map(([userId, cursor]) => (
<g key={userId} opacity={userId === profile.userId ? 0.9 : 0.78}>
<circle cx={cursor.x} cy={cursor.y} r="8" fill={cursor.color} />
<rect x={cursor.x + 12} y={cursor.y - 18} rx="8" ry="8" width="108" height="24" fill="rgba(15,23,42,0.78)" />
<text x={cursor.x + 22} y={cursor.y - 2} fill="#f8fafc" fontSize="12" fontWeight="700">
{cursor.name}
</text>
</g>
))}
</svg>
</div>
<p className="mt-3 text-xs text-slate-600">
Draw with one finger or mouse. Eraser uses a fat paper-colored stroke to keep the shared payload simple.
</p>
</section>
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<article className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="text-[10px] font-black uppercase tracking-[0.24em] text-slate-400">Live Presence</p>
<div className="mt-3 flex flex-wrap gap-2">
{visibleCursors.length === 0 ? (
<p className="text-sm text-slate-500">No active cursors yet.</p>
) : (
visibleCursors.map(([userId, cursor]) => (
<div key={userId} className="inline-flex items-center gap-2 rounded-full bg-slate-900/35 px-3 py-2 text-xs text-white">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: cursor.color }} />
<span className="font-bold">{cursor.name}</span>
</div>
))
)}
</div>
</article>
<article className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="text-[10px] font-black uppercase tracking-[0.24em] text-slate-400">Board Notes</p>
<ul className="mt-3 space-y-2 text-sm text-slate-300">
<li>History stays capped at {MAX_STROKES} strokes.</li>
<li>Each stroke keeps at most {MAX_POINTS} points.</li>
<li>{sharedEnabled ? 'Shared sync is active for this room.' : 'This session is in local-only fallback mode.'}</li>
</ul>
</article>
</section>
<footer className="rounded-[22px] border border-white/10 bg-slate-950/30 p-3 text-xs text-slate-400">
<p>{notice}</p>
</footer>
</section>
</main>
);
}
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **Do NOT import external JS libraries via script tags** — they will be blocked. Include library code inline or use a CDN link in a `<link>` tag for CSS only.
6. **Keep total code under 80KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained JSX file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/live-room-whiteboard */