0
GAMES#5c45ec98
Decision Dice
@guest-27811146·deposited 2d ago·updated 2d ago·14 views
GAMES#5c45ec98
Decision Dice
GU
@guest-27811146
14Views
0Comments
0Forks
0Saves
SHARE · REMIX
Decision Dice — a JSX Games widget by @guest-27811146.
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: "Decision Dice" by @guest-27811146]
Source: https://itjustvibes.com/guest-27811146/decision-dice
Type: React/JSX
--- SOURCE CODE ---
```jsx
function Widget() {
const STORE_KEY = 'decision-dice:v1';
const MAX_LISTS = 12;
const MAX_OPTIONS = 16;
const MAX_HISTORY = 24;
const WEIGHTS = [1, 2, 3, 4, 5];
const STARTER = {
activeListId: 'lunch',
lists: [
{
id: 'lunch',
name: 'Where to eat',
favorite: true,
options: [
{ id: 'opt-tacos', label: 'Taco truck', weight: 3 },
{ id: 'opt-ramen', label: 'Ramen bar', weight: 2 },
{ id: 'opt-leftovers', label: 'Sad desk leftovers', weight: 1 },
],
},
{
id: 'tonight',
name: 'Tonight',
favorite: false,
options: [
{ id: 'opt-movie', label: 'Watch a movie', weight: 2 },
{ id: 'opt-walk', label: 'Long walk', weight: 2 },
{ id: 'opt-code', label: 'Build a dumb widget', weight: 3 },
{ id: 'opt-sleep', label: 'Sleep, honestly', weight: 1 },
],
},
],
history: [],
};
const memoryRef = React.useRef(null);
const [ready, setReady] = React.useState(false);
const [persistMode, setPersistMode] = React.useState('memory');
const [state, setState] = React.useState(STARTER);
const [rolling, setRolling] = React.useState(false);
const [face, setFace] = React.useState(null);
const [result, setResult] = React.useState(null);
const [draftLabel, setDraftLabel] = React.useState('');
const [draftWeight, setDraftWeight] = React.useState(2);
const [reducedMotion, setReducedMotion] = React.useState(false);
const rollTimer = React.useRef(null);
const tickTimer = React.useRef(null);
const saveTimer = React.useRef(null);
React.useEffect(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setReducedMotion(!!mq.matches);
const onChange = (e) => setReducedMotion(!!e.matches);
if (mq.addEventListener) mq.addEventListener('change', onChange);
return () => {
if (mq.removeEventListener) mq.removeEventListener('change', onChange);
};
}
}, []);
React.useEffect(() => {
let cancelled = false;
memoryRef.current = sanitizeState(null);
const api = window.vibes;
if (!api || typeof api.onReady !== 'function') {
setState(memoryRef.current);
setReady(true);
return;
}
api.onReady(async () => {
let saved = null;
try {
saved = await api.load(STORE_KEY);
} catch (err) {
console.error('load failed:', err && err.message);
}
if (cancelled) return;
const next = sanitizeState(saved);
memoryRef.current = next;
setState(next);
setPersistMode('vibes');
setReady(true);
});
return () => {
cancelled = true;
clearTimeout(rollTimer.current);
clearInterval(tickTimer.current);
clearTimeout(saveTimer.current);
};
}, []);
function persist(next) {
memoryRef.current = next;
setState(next);
const api = window.vibes;
if (!api || typeof api.save !== 'function') return;
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
api.save(STORE_KEY, next).catch((e) => console.error('save failed:', e && e.message));
}, 400);
}
function uid(prefix) {
return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
function clampWeight(value) {
const n = Math.round(Number(value));
if (!Number.isFinite(n)) return 1;
return Math.max(1, Math.min(5, n));
}
function sanitizeOption(opt) {
if (!opt || typeof opt !== 'object') return null;
const label = typeof opt.label === 'string' ? opt.label.trim().slice(0, 48) : '';
if (!label) return null;
return {
id: typeof opt.id === 'string' && opt.id ? opt.id : uid('opt'),
label,
weight: clampWeight(opt.weight),
};
}
function sanitizeList(list) {
if (!list || typeof list !== 'object') return null;
const name = typeof list.name === 'string' && list.name.trim() ? list.name.trim().slice(0, 40) : 'Untitled list';
const options = (Array.isArray(list.options) ? list.options : [])
.map(sanitizeOption)
.filter(Boolean)
.slice(0, MAX_OPTIONS);
return {
id: typeof list.id === 'string' && list.id ? list.id : uid('list'),
name,
favorite: !!list.favorite,
options,
};
}
function sanitizeState(saved) {
let lists = (Array.isArray(saved && saved.lists) ? saved.lists : [])
.map(sanitizeList)
.filter(Boolean)
.slice(0, MAX_LISTS);
if (!lists.length) {
lists = STARTER.lists.map((l) => ({ ...l, options: l.options.map((o) => ({ ...o })) }));
}
const activeListId = lists.some((l) => l.id === (saved && saved.activeListId)) ? saved.activeListId : lists[0].id;
const history = (Array.isArray(saved && saved.history) ? saved.history : [])
.map((h) => {
if (!h || typeof h !== 'object') return null;
const label = typeof h.label === 'string' ? h.label.slice(0, 48) : '';
if (!label) return null;
return {
id: typeof h.id === 'string' && h.id ? h.id : uid('roll'),
label,
listName: typeof h.listName === 'string' ? h.listName.slice(0, 40) : '',
at: typeof h.at === 'number' ? h.at : Date.now(),
};
})
.filter(Boolean)
.slice(0, MAX_HISTORY);
return { activeListId, lists, history };
}
const activeList = state.lists.find((l) => l.id === state.activeListId) || state.lists[0] || null;
const options = activeList ? activeList.options : [];
const totalWeight = options.reduce((sum, o) => sum + o.weight, 0);
// Weighted random pick: r in [0, totalWeight); walk cumulative weights.
// Example: weights [3,2,1] -> total 6. r<3 => first (50%), 3<=r<5 => second (33.3%), 5<=r<6 => third (16.7%).
function pickWeighted(opts) {
const total = opts.reduce((sum, o) => sum + o.weight, 0);
if (total <= 0) return null;
let r = Math.random() * total;
for (let i = 0; i < opts.length; i += 1) {
r -= opts[i].weight;
if (r < 0) return opts[i];
}
return opts[opts.length - 1];
}
function percentFor(weight) {
if (totalWeight <= 0) return 0;
return Math.round((weight / totalWeight) * 1000) / 10;
}
function roll() {
if (rolling || options.length < 1 || totalWeight <= 0) return;
const winner = pickWeighted(options);
if (!winner) return;
setResult(null);
if (reducedMotion) {
setFace(winner.label);
finishRoll(winner);
return;
}
setRolling(true);
clearInterval(tickTimer.current);
clearTimeout(rollTimer.current);
const labels = options.map((o) => o.label);
let i = 0;
tickTimer.current = setInterval(() => {
i += 1;
setFace(labels[Math.floor(Math.random() * labels.length)] || winner.label);
}, 90);
rollTimer.current = setTimeout(() => {
clearInterval(tickTimer.current);
setFace(winner.label);
finishRoll(winner);
}, 1150);
}
function finishRoll(winner) {
setRolling(false);
setResult(winner);
const entry = {
id: uid('roll'),
label: winner.label,
listName: activeList ? activeList.name : '',
at: Date.now(),
};
persist({ ...memoryRef.current, history: [entry, ...memoryRef.current.history].slice(0, MAX_HISTORY) });
}
function selectList(id) {
if (rolling) return;
setResult(null);
setFace(null);
persist({ ...state, activeListId: id });
}
function addList() {
if (state.lists.length >= MAX_LISTS) return;
const list = { id: uid('list'), name: `New list ${state.lists.length + 1}`, favorite: false, options: [] };
persist({ ...state, lists: [...state.lists, list], activeListId: list.id });
setResult(null);
setFace(null);
}
function renameList(value) {
if (!activeList) return;
const name = value.slice(0, 40);
persist({
...state,
lists: state.lists.map((l) => (l.id === activeList.id ? { ...l, name } : l)),
});
}
function toggleFavorite() {
if (!activeList) return;
persist({
...state,
lists: state.lists.map((l) => (l.id === activeList.id ? { ...l, favorite: !l.favorite } : l)),
});
}
function deleteList() {
if (!activeList || state.lists.length <= 1) return;
const remaining = state.lists.filter((l) => l.id !== activeList.id);
persist({ ...state, lists: remaining, activeListId: remaining[0].id });
setResult(null);
setFace(null);
}
function addOption(event) {
event.preventDefault();
if (!activeList) return;
const label = draftLabel.trim().slice(0, 48);
if (!label || activeList.options.length >= MAX_OPTIONS) return;
const option = { id: uid('opt'), label, weight: clampWeight(draftWeight) };
persist({
...state,
lists: state.lists.map((l) => (l.id === activeList.id ? { ...l, options: [...l.options, option] } : l)),
});
setDraftLabel('');
setResult(null);
}
function setOptionWeight(optionId, weight) {
if (!activeList) return;
persist({
...state,
lists: state.lists.map((l) =>
l.id === activeList.id
? { ...l, options: l.options.map((o) => (o.id === optionId ? { ...o, weight: clampWeight(weight) } : o)) }
: l
),
});
}
function removeOption(optionId) {
if (!activeList) return;
persist({
...state,
lists: state.lists.map((l) =>
l.id === activeList.id ? { ...l, options: l.options.filter((o) => o.id !== optionId) } : l
),
});
setResult(null);
}
function clearHistory() {
persist({ ...state, history: [] });
}
function timeAgo(at) {
const diff = Math.max(0, Date.now() - at);
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
const fontCss = `
@keyframes dd-tumble {
0% { transform: rotateX(0deg) rotateY(0deg) scale(1); }
25% { transform: rotateX(180deg) rotateY(90deg) scale(1.04); }
50% { transform: rotateX(360deg) rotateY(180deg) scale(0.98); }
75% { transform: rotateX(520deg) rotateY(300deg) scale(1.04); }
100% { transform: rotateX(720deg) rotateY(360deg) scale(1); }
}
@keyframes dd-pop {
0% { transform: scale(0.82); opacity: 0; }
60% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes dd-glow {
0%,100% { box-shadow: 0 0 0 1px rgba(56,248,215,0.35), 0 18px 50px -12px rgba(56,248,215,0.5); }
50% { box-shadow: 0 0 0 1px rgba(255,79,216,0.4), 0 18px 60px -10px rgba(255,79,216,0.55); }
}
.dd-die { animation: dd-glow 4.5s ease-in-out infinite; }
.dd-die-rolling { animation: dd-tumble 1.15s cubic-bezier(.2,.7,.2,1) both, dd-glow 4.5s ease-in-out infinite; }
.dd-pop { animation: dd-pop .42s cubic-bezier(.2,.8,.2,1) both; }
.dd-grain { background-image: radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px); background-size: 4px 4px; }
@media (prefers-reduced-motion: reduce) {
.dd-die, .dd-die-rolling, .dd-pop { animation: none !important; }
}
`;
if (!ready) {
return (
<main className="min-h-screen bg-[#0a0612] p-4 text-zinc-100">
<style>{fontCss}</style>
<section className="mx-auto flex min-h-[60vh] max-w-md items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="dd-die h-20 w-20 rounded-2xl border border-cyan-300/40 bg-gradient-to-br from-fuchsia-600/40 to-cyan-500/30" />
<p className="text-xs font-bold uppercase tracking-[0.4em] text-cyan-300/80">Loading dice...</p>
</div>
</section>
</main>
);
}
const faceLabel = face || (result ? result.label : (options[0] ? options[0].label : '--'));
const showResultRing = !!result && !rolling;
return (
<main className="relative min-h-screen overflow-hidden bg-[radial-gradient(circle_at_18%_-5%,rgba(255,79,216,0.22),transparent_42%),radial-gradient(circle_at_92%_8%,rgba(56,248,215,0.18),transparent_38%),linear-gradient(180deg,#0a0612_0%,#0d0a1d_55%,#070410_100%)] px-3 pb-8 pt-4 text-zinc-100 sm:px-5">
<style>{fontCss}</style>
<div className="pointer-events-none absolute inset-0 dd-grain opacity-60" />
<section className="relative mx-auto flex max-w-5xl flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span
className="grid h-11 w-11 place-items-center rounded-xl border border-cyan-300/40 bg-zinc-950 text-lg"
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
>
⚄
</span>
<div>
<h1
className="text-2xl font-black uppercase leading-none tracking-[0.12em] text-white sm:text-3xl"
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
>
Decision <span className="text-cyan-300">Dice</span>
</h1>
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-fuchsia-300/80">
Weighted random ruler
</p>
</div>
</div>
<span
className={`rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-[0.22em] ${
persistMode === 'vibes'
? 'border-cyan-300/40 bg-cyan-300/10 text-cyan-200'
: 'border-zinc-700 bg-zinc-900 text-zinc-400'
}`}
>
{persistMode === 'vibes' ? 'Saved' : 'Memory mode'}
</span>
</header>
{/* The die dominates on load. */}
<section className="rounded-[28px] border border-white/10 bg-zinc-950/60 p-5 shadow-[0_24px_70px_-30px_rgba(255,79,216,0.6)] backdrop-blur sm:p-7">
<div className="flex flex-col items-center gap-5">
<div className="relative grid place-items-center" style={{ perspective: '900px' }}>
<div
key={rolling ? 'rolling' : faceLabel}
className={`grid h-44 w-44 place-items-center rounded-[28px] border-2 px-4 text-center sm:h-52 sm:w-52 ${
rolling ? 'dd-die-rolling' : 'dd-die'
} ${showResultRing ? 'border-cyan-300' : 'border-fuchsia-400/60'}`}
style={{
background:
'linear-gradient(150deg, rgba(255,79,216,0.22), rgba(13,10,29,0.95) 45%, rgba(56,248,215,0.18))',
transformStyle: 'preserve-3d',
}}
>
<span
className={`block break-words text-xl font-black leading-tight text-white sm:text-2xl ${
showResultRing ? 'dd-pop' : ''
}`}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
>
{totalWeight > 0 ? faceLabel : 'Add an option'}
</span>
</div>
</div>
<div className="min-h-[1.5rem] text-center">
{rolling ? (
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-fuchsia-300">Rolling...</p>
) : result ? (
<p className="dd-pop text-sm font-semibold text-cyan-200">
The dice chose <span className="font-black text-white">{result.label}</span>
</p>
) : (
<p className="text-sm text-zinc-400">
{totalWeight > 0
? `${options.length} options · ${totalWeight} total weight`
: 'This list is empty. Add options below to roll.'}
</p>
)}
</div>
<button
type="button"
onClick={roll}
disabled={rolling || totalWeight <= 0}
className={`group relative w-full max-w-xs rounded-2xl px-6 py-4 text-base font-black uppercase tracking-[0.2em] transition active:scale-[0.98] ${
rolling || totalWeight <= 0
? 'cursor-not-allowed border border-zinc-800 bg-zinc-900 text-zinc-600'
: 'border border-cyan-300/60 bg-gradient-to-r from-fuchsia-500 to-cyan-400 text-zinc-950 shadow-[0_14px_40px_-12px_rgba(56,248,215,0.7)] hover:brightness-110'
}`}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
>
{rolling ? 'Rolling' : 'Roll the dice'}
</button>
</div>
</section>
<section className="grid gap-4 lg:grid-cols-[1.25fr_0.75fr]">
{/* List + options editor */}
<div className="flex flex-col gap-4">
<div className="rounded-[24px] border border-white/10 bg-zinc-950/50 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
{state.lists.map((list) => {
const isActive = activeList && list.id === activeList.id;
return (
<button
key={list.id}
type="button"
onClick={() => selectList(list.id)}
className={`rounded-full border px-3 py-1.5 text-xs font-bold transition ${
isActive
? 'border-cyan-300/70 bg-cyan-300/15 text-cyan-100'
: 'border-zinc-800 bg-zinc-900/70 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
}`}
>
{list.favorite ? '★ ' : ''}
{list.name}
</button>
);
})}
{state.lists.length < MAX_LISTS ? (
<button
type="button"
onClick={addList}
className="rounded-full border border-dashed border-fuchsia-400/50 px-3 py-1.5 text-xs font-bold text-fuchsia-200 hover:bg-fuchsia-400/10"
>
+ New list
</button>
) : null}
</div>
{activeList ? (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={activeList.name}
onChange={(e) => renameList(e.target.value)}
className="min-w-0 flex-1 rounded-xl border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-bold text-white outline-none focus:border-cyan-300/70"
placeholder="List name"
/>
<button
type="button"
onClick={toggleFavorite}
className={`rounded-xl border px-3 py-2 text-xs font-bold transition ${
activeList.favorite
? 'border-amber-300/60 bg-amber-300/15 text-amber-200'
: 'border-zinc-800 bg-zinc-900 text-zinc-400 hover:text-amber-200'
}`}
>
{activeList.favorite ? '★ Favorite' : '☆ Favorite'}
</button>
<button
type="button"
onClick={deleteList}
disabled={state.lists.length <= 1}
className={`rounded-xl border px-3 py-2 text-xs font-bold transition ${
state.lists.length <= 1
? 'cursor-not-allowed border-zinc-900 bg-zinc-900 text-zinc-700'
: 'border-zinc-800 bg-zinc-900 text-rose-300 hover:border-rose-400/50 hover:bg-rose-500/10'
}`}
>
Delete
</button>
</div>
<form onSubmit={addOption} className="flex flex-wrap items-stretch gap-2">
<input
value={draftLabel}
onChange={(e) => setDraftLabel(e.target.value.slice(0, 48))}
placeholder="Add an option..."
className="min-w-0 flex-1 rounded-xl border border-zinc-800 bg-zinc-900 px-3 py-2.5 text-sm text-white outline-none focus:border-fuchsia-400/70"
/>
<select
value={draftWeight}
onChange={(e) => setDraftWeight(clampWeight(e.target.value))}
className="rounded-xl border border-zinc-800 bg-zinc-900 px-3 py-2.5 text-sm font-bold text-zinc-100 outline-none focus:border-fuchsia-400/70"
title="Weight (higher = more likely)"
>
{WEIGHTS.map((w) => (
<option key={w} value={w}>
*{w}
</option>
))}
</select>
<button
type="submit"
disabled={!draftLabel.trim() || activeList.options.length >= MAX_OPTIONS}
className={`rounded-xl px-4 py-2.5 text-sm font-black transition ${
!draftLabel.trim() || activeList.options.length >= MAX_OPTIONS
? 'cursor-not-allowed bg-zinc-800 text-zinc-600'
: 'bg-cyan-300 text-zinc-950 hover:brightness-110'
}`}
>
Add
</button>
</form>
{options.length === 0 ? (
<div className="rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/40 p-6 text-center">
<p className="text-sm font-bold text-white">No options yet.</p>
<p className="mt-1 text-xs text-zinc-400">
Add a couple of choices and tweak each one's weight to load the dice.
</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{options.map((option) => {
const pct = percentFor(option.weight);
const isWinner = result && result.id === option.id && !rolling;
return (
<li
key={option.id}
className={`overflow-hidden rounded-2xl border bg-zinc-900/60 transition ${
isWinner ? 'border-cyan-300/70 ring-1 ring-cyan-300/40' : 'border-zinc-800'
}`}
>
<div className="relative px-3 py-2.5">
<div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-fuchsia-500/25 to-cyan-400/15"
style={{ width: `${pct}%` }}
/>
<div className="relative flex items-center justify-between gap-3">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-white">
{option.label}
</span>
<span
className="shrink-0 text-xs font-bold tabular-nums text-cyan-200"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{pct}%
</span>
<div className="flex shrink-0 items-center overflow-hidden rounded-lg border border-zinc-700">
{WEIGHTS.map((w) => (
<button
key={w}
type="button"
onClick={() => setOptionWeight(option.id, w)}
className={`px-2 py-1 text-[11px] font-bold transition ${
option.weight === w
? 'bg-cyan-300 text-zinc-950'
: 'bg-zinc-950 text-zinc-400 hover:text-zinc-100'
}`}
title={`Weight ${w}`}
>
{w}
</button>
))}
</div>
<button
type="button"
onClick={() => removeOption(option.id)}
className="shrink-0 rounded-lg border border-zinc-800 px-2 py-1 text-xs font-bold text-rose-300 transition hover:border-rose-400/50 hover:bg-rose-500/10"
aria-label={`Remove ${option.label}`}
>
✕
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
<p className="text-[11px] text-zinc-500">
Weight sets the odds: a *3 option is rolled three times as often as a *1. Percentages above are live.
</p>
</div>
) : null}
</div>
</div>
{/* History */}
<aside className="rounded-[24px] border border-white/10 bg-zinc-950/50 p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-zinc-300">Roll log</h2>
{state.history.length ? (
<button
type="button"
onClick={clearHistory}
className="rounded-lg border border-zinc-800 px-2.5 py-1 text-[11px] font-bold text-zinc-400 transition hover:border-rose-400/40 hover:text-rose-300"
>
Clear
</button>
) : null}
</div>
{state.history.length === 0 ? (
<div className="rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/40 p-6 text-center">
<p className="text-sm font-bold text-white">No rolls yet.</p>
<p className="mt-1 text-xs text-zinc-400">Your last {MAX_HISTORY} decisions will land here.</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{state.history.map((entry, idx) => (
<li
key={entry.id}
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2 ${
idx === 0 ? 'border-cyan-300/40 bg-cyan-300/5' : 'border-zinc-800 bg-zinc-900/40'
}`}
>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-white">{entry.label}</p>
{entry.listName ? (
<p className="truncate text-[11px] text-zinc-500">{entry.listName}</p>
) : null}
</div>
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-zinc-400">
{timeAgo(entry.at)}
</span>
</li>
))}
</ul>
)}
</aside>
</section>
</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/guest-27811146/decision-dice */