0
GAMES#e50266c8
Fortune Cookie (fork)
Forked from@princess-peach/Fortune Cookie
@princess-peach·deposited 3w ago·updated 3w ago·72 views
GAMES#e50266c8
Fortune Cookie (fork)
PR
@princess-peach
72Views
0Comments
0Forks
0Saves
SHARE · REMIX
Fortune Cookie (fork) — a JSX games widget by @princess-peach.
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: "Fortune Cookie (fork)" by @princess-peach]
Source: https://itjustvibes.com/princess-peach/fortune-cookie-fork
Type: React/JSX
--- SOURCE CODE ---
```jsx
function Widget() {
const STORE_KEY = 'fortune-cookie:v1';
const GOLD_ODDS = 1 / 12;
const MAX_FAVORITES = 50;
const MAX_SEEN = 200;
// Authored fortune bank. `g: true` marks a rare "golden" fortune.
const FORTUNES = [
{ t: 'Your CSS will betray you, but your button will ship.' },
{ t: 'A bug you cannot reproduce is just a feature meditating.' },
{ t: 'Ship the ugly version today. The beautiful one never exists.' },
{ t: 'The widget you abandoned last month is quietly your best idea.' },
{ t: 'Someone will fork your jank and call it genius. Let them.' },
{ t: 'Today you will name a variable `temp` and it will outlive the empire.' },
{ t: 'The console is screaming. The console has always been screaming. Listen anyway.' },
{ t: 'Your next great feature is hiding inside a typo you have not made yet.' },
{ t: 'Delete the comment that says "do not delete." It was never load-bearing.' },
{ t: 'A flexbox will humble you before noon. Center yourself, then the div.' },
{ t: 'The demo gods favor those who clear their cache before presenting.' },
{ t: 'You will copy a snippet from yourself from two years ago and not recognize the genius.' },
{ t: 'Refactor later. "Later" is a place; you have never been there; it is fine.' },
{ t: 'The user does not want more options. The user wants one big friendly button.' },
{ t: 'Your loading spinner spins for you, not the network. Be grateful to it.' },
{ t: 'A merge conflict approaches. Choose "accept both" and accept your fate.' },
{ t: 'The feature nobody asked for is the one they will not stop using.' },
{ t: 'You will ship at 2am and it will be your finest work and your worst commit message.' },
{ t: 'Trust the rubber duck. The duck has seen things.' },
{ t: 'Your dark mode is not done until the one white flash at the edge is gone. It mocks you.' },
{ t: 'The off-by-one error is not a mistake. It is the universe keeping score.' },
{ t: 'You will spend an hour on the animation nobody notices, and it will be worth it.' },
{ t: 'A semicolon you forgot is forgiving you. A semicolon you added is plotting.' },
{ t: 'Name it before you build it. Unnamed widgets wander the codebase forever.' },
{ t: 'The simplest thing that could possibly work is also the funniest. Do that.' },
{ t: 'Your TODO list is a museum. Walk through it gently and burn one exhibit.' },
{ t: 'A stranger will use your widget at the wrong scale and discover something beautiful.' },
{ t: 'The regex works. Do not look at it again. Do not. It is sleeping.' },
{ t: 'You are three keystrokes from joy and one stray bracket from chaos. Both are growth.' },
{ t: 'Stop reading the docs. The docs are afraid of you now.' },
{ t: 'Today the build passes on the first try. Tell no one. They will not believe you.' },
{ t: 'Your most-used shortcut will fail you on the most important day. Learn a backup.' },
{ t: 'The widget wants to be smaller than you think. Cut a feature; it will thank you.' },
{ t: 'A bold color choice today saves a thousand gray cards tomorrow.' },
{ t: 'You will explain the bug out loud and fix it before finishing the sentence.' },
{ t: 'The empty state is the first impression. Make it smile, not shrug.' },
{ t: 'GOLDEN: The platform sees you. Your jank ascends to legend tonight.', g: true },
{ t: 'GOLDEN: Every button you press today lands. Ship freely, fortunate one.', g: true },
{ t: 'GOLDEN: A fork tree will bloom from a widget you make this week.', g: true },
{ t: 'GOLDEN: The rarest luck -- your first deploy works and you understand why.', g: true },
{ t: 'GOLDEN: Six numbers, one idea, zero doubts. The cookie chose you.', g: true },
{ t: 'GOLDEN: Today your bug count goes negative. You are fixing the future.', g: true },
];
function defaultState() {
return {
favorites: [],
seenIds: [],
deck: shuffledDeck(),
cursor: 0,
streak: 0,
bestStreak: 0,
totalCracks: 0,
lastCrackDay: null,
};
}
// ---- pure helpers -------------------------------------------------------
function shuffledDeck() {
const arr = FORTUNES.map((_, i) => i);
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
function luckyNumbers() {
const out = [];
let guard = 0;
while (out.length < 6 && guard < 500) {
const n = 1 + Math.floor(Math.random() * 49);
if (!out.includes(n)) out.push(n);
guard += 1;
}
return out;
}
function todayStr() {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${day}`;
}
function dayDiff(fromStr, toStr) {
const a = new Date(`${fromStr}T00:00:00`);
const b = new Date(`${toStr}T00:00:00`);
if (isNaN(a.getTime()) || isNaN(b.getTime())) return Infinity;
return Math.round((b.getTime() - a.getTime()) / 86400000);
}
function nextStreak(prevStreak, lastDay, today) {
if (!lastDay) return 1;
const diff = dayDiff(lastDay, today);
if (diff === 0) return Math.max(1, prevStreak);
if (diff === 1) return Math.max(1, prevStreak) + 1;
return 1;
}
function fortuneId(index) {
return `f${index}`;
}
function sanitize(saved) {
const base = defaultState();
if (!saved || typeof saved !== 'object') return base;
const favorites = Array.isArray(saved.favorites)
? saved.favorites
.map((f) => {
if (!f || typeof f !== 'object') return null;
const text = typeof f.text === 'string' ? f.text.slice(0, 220) : '';
if (!text) return null;
const numbers = Array.isArray(f.numbers)
? f.numbers.filter((n) => Number.isFinite(n)).slice(0, 6)
: [];
return {
id: typeof f.id === 'string' ? f.id.slice(0, 16) : `fav-${Math.random().toString(36).slice(2, 7)}`,
text,
golden: f.golden === true,
numbers,
savedAt: Number.isFinite(f.savedAt) ? f.savedAt : Date.now(),
};
})
.filter(Boolean)
.slice(0, MAX_FAVORITES)
: [];
const seenIds = Array.isArray(saved.seenIds)
? saved.seenIds.filter((s) => typeof s === 'string').slice(-MAX_SEEN)
: [];
let deck = Array.isArray(saved.deck)
? saved.deck.filter((n) => Number.isInteger(n) && n >= 0 && n < FORTUNES.length)
: [];
if (deck.length !== FORTUNES.length) deck = shuffledDeck();
let cursor = Number.isInteger(saved.cursor) ? saved.cursor : 0;
if (cursor < 0 || cursor > deck.length) cursor = 0;
const streak = Number.isInteger(saved.streak) && saved.streak >= 0 ? saved.streak : 0;
const totalCracks = Number.isInteger(saved.totalCracks) && saved.totalCracks >= 0 ? saved.totalCracks : 0;
const bestStreak = Math.max(streak, Number.isInteger(saved.bestStreak) ? saved.bestStreak : 0);
const lastCrackDay = typeof saved.lastCrackDay === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(saved.lastCrackDay)
? saved.lastCrackDay
: null;
return { favorites, seenIds, deck, cursor, streak, bestStreak, totalCracks, lastCrackDay };
}
// Draw the next fortune index from the deck, reshuffling on exhaustion and
// avoiding an immediate repeat of `avoidIndex` when possible.
function drawNext(deck, cursor, avoidIndex) {
let d = deck;
let c = cursor;
if (c >= d.length) {
d = shuffledDeck();
c = 0;
if (avoidIndex != null && d[0] === avoidIndex && d.length > 1) {
const tmp = d[0];
d[0] = d[1];
d[1] = tmp;
}
}
const index = d[c];
return { index, deck: d, cursor: c + 1 };
}
// ---- component state ----------------------------------------------------
const [ready, setReady] = React.useState(false);
const [persistMode, setPersistMode] = React.useState('memory'); // 'memory' | 'vibes'
const [state, setState] = React.useState(defaultState);
const stateRef = React.useRef(state);
stateRef.current = state;
const [current, setCurrent] = React.useState(null); // { index, text, golden, numbers, id }
const [phase, setPhase] = React.useState('intact'); // 'intact' | 'cracking' | 'open'
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const [toast, setToast] = React.useState('');
const reduceMotion = React.useRef(false);
React.useEffect(() => {
try {
reduceMotion.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (err) {
reduceMotion.current = false;
}
}, []);
// Load once on mount, with SDK-absent fallback.
React.useEffect(() => {
let cancelled = false;
const api = window.vibes;
if (!api || typeof api.onReady !== 'function') {
setReady(true);
return () => {};
}
api.onReady(async () => {
try {
const saved = await api.load(STORE_KEY);
if (cancelled) return;
if (saved) setState(sanitize(saved));
setPersistMode('vibes');
} catch (err) {
if (typeof console !== 'undefined') console.error('fortune load failed:', err && err.message);
} finally {
if (!cancelled) setReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
const saveTimer = React.useRef(null);
function persist(next) {
setState(next);
stateRef.current = 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) => {
if (typeof console !== 'undefined') console.error('fortune save failed:', e && e.message);
});
}, 400);
}
const toastTimer = React.useRef(null);
function flashToast(msg) {
setToast(msg);
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(''), 1800);
}
React.useEffect(() => () => {
clearTimeout(saveTimer.current);
clearTimeout(toastTimer.current);
}, []);
// ---- actions ------------------------------------------------------------
function crack() {
if (phase === 'cracking') return;
const s = stateRef.current;
const avoid = current ? current.index : null;
const golden = Math.random() < GOLD_ODDS;
// Bias toward a golden fortune when the roll is golden, else any non-golden.
let draw = drawNext(s.deck, s.cursor, avoid);
let safety = 0;
while (
safety < FORTUNES.length * 2 &&
((golden && !FORTUNES[draw.index].g) || (!golden && FORTUNES[draw.index].g))
) {
draw = drawNext(draw.deck, draw.cursor, avoid);
safety += 1;
}
const meta = FORTUNES[draw.index];
const text = meta.t.replace(/^GOLDEN:\s*/, '');
const numbers = luckyNumbers();
const next = {
index: draw.index,
id: fortuneId(draw.index),
text,
golden: meta.g === true,
numbers,
};
const today = todayStr();
const seenIds = s.seenIds.includes(next.id) ? s.seenIds : s.seenIds.concat(next.id).slice(-MAX_SEEN);
const streak = nextStreak(s.streak, s.lastCrackDay, today);
const nextState = {
...s,
deck: draw.deck,
cursor: draw.cursor,
seenIds,
streak,
bestStreak: Math.max(s.bestStreak, streak),
totalCracks: s.totalCracks + 1,
lastCrackDay: today,
};
setCurrent(next);
setCopied(false);
if (reduceMotion.current) {
setPhase('open');
} else {
setPhase('cracking');
window.setTimeout(() => setPhase('open'), 520);
}
persist(nextState);
}
function resetCookie() {
setPhase('intact');
setCurrent(null);
setCopied(false);
}
const isFavorited = current
? state.favorites.some((f) => f.text === current.text)
: false;
function toggleFavorite() {
if (!current) return;
const s = stateRef.current;
if (s.favorites.some((f) => f.text === current.text)) {
persist({ ...s, favorites: s.favorites.filter((f) => f.text !== current.text) });
flashToast('Removed from favorites');
} else {
const fav = {
id: `fav-${current.index}-${Date.now().toString(36)}`,
text: current.text,
golden: current.golden,
numbers: current.numbers.slice(0, 6),
savedAt: Date.now(),
};
persist({ ...s, favorites: [fav].concat(s.favorites).slice(0, MAX_FAVORITES) });
flashToast('Saved to favorites');
}
}
function removeFavorite(id) {
const s = stateRef.current;
persist({ ...s, favorites: s.favorites.filter((f) => f.id !== id) });
}
function shareText(text, numbers) {
const lucky = numbers && numbers.length ? `\n\nLucky build numbers: ${numbers.join(' ')}` : '';
return `${text}${lucky}\n\n-- Jank Fortune Cookie · It Just Vibes`;
}
async function copyCurrent() {
if (!current) return;
const payload = shareText(current.text, current.numbers);
let ok = false;
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(payload);
ok = true;
}
} catch (err) {
ok = false;
}
if (!ok) ok = legacyCopy(payload);
if (ok) {
setCopied(true);
flashToast('Fortune copied');
window.setTimeout(() => setCopied(false), 1600);
} else {
flashToast('Copy blocked -- select & copy manually');
}
}
function legacyCopy(text) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand && document.execCommand('copy');
document.body.removeChild(ta);
return !!ok;
} catch (err) {
return false;
}
}
// ---- derived ------------------------------------------------------------
const seenCount = state.seenIds.length;
const motionClass = reduceMotion.current ? '' : 'jfc-anim';
// ---- styles -------------------------------------------------------------
const styleTag = (
<style>{`
.jfc-root {
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
}
.jfc-display {
font-family: "Cinzel", "Trajan Pro", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
letter-spacing: 0.02em;
}
.jfc-foil {
background: linear-gradient(100deg, #b8862f 0%, #f7e7a6 28%, #c69a3c 52%, #fff3c4 70%, #b8862f 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.jfc-stage { background-image: linear-gradient(180deg, rgba(70,18,26,0.55), rgba(12,4,5,0.7)); }
.jfc-gold-btn { background-image: linear-gradient(180deg, #d6a64a, #a9761c); }
.jfc-sunburst {
background:
repeating-conic-gradient(from 0deg at 50% 50%,
rgba(214,166,74,0.16) 0deg 7deg,
rgba(0,0,0,0) 7deg 14deg);
}
.jfc-cookie { transition: transform .35s ease, filter .35s ease; }
.jfc-anim .jfc-cookie:hover { transform: translateY(-4px) rotate(-3deg); filter: drop-shadow(0 14px 26px rgba(214,166,74,.35)); }
.jfc-cookie:active { transform: scale(.97); }
.jfc-half { transition: transform .5s cubic-bezier(.18,.9,.3,1.2), opacity .5s ease; }
.jfc-cracking .jfc-half-l { transform: translate(-34%, 10%) rotate(-22deg); }
.jfc-cracking .jfc-half-r { transform: translate(34%, 8%) rotate(20deg); }
.jfc-open .jfc-half-l { transform: translate(-46%, 16%) rotate(-26deg); opacity:.85; }
.jfc-open .jfc-half-r { transform: translate(46%, 14%) rotate(24deg); opacity:.85; }
.jfc-slip { transform: translateY(8px) scale(.96); opacity: 0; transition: transform .45s ease .18s, opacity .4s ease .18s; }
.jfc-open .jfc-slip { transform: translateY(0) scale(1); opacity: 1; }
.jfc-pulse { animation: jfc-pulse 2.2s ease-in-out infinite; }
@keyframes jfc-pulse { 0%,100% { transform: scale(1); opacity:.85 } 50% { transform: scale(1.06); opacity:1 } }
.jfc-spin { animation: jfc-spin 26s linear infinite; }
@keyframes jfc-spin { to { transform: rotate(360deg) } }
.jfc-rise { animation: jfc-rise .5s ease both; }
@keyframes jfc-rise { from { opacity:0; transform: translateY(10px) } to { opacity:1; transform:none } }
.jfc-num { animation: jfc-pop .4s cubic-bezier(.2,.8,.3,1.4) both; }
@keyframes jfc-pop { from { transform: scale(.4); opacity:0 } to { transform: scale(1); opacity:1 } }
@media (prefers-reduced-motion: reduce) {
.jfc-spin, .jfc-pulse, .jfc-rise, .jfc-num, .jfc-slip, .jfc-half, .jfc-cookie { animation: none !important; transition: none !important; }
.jfc-slip { opacity: 1 !important; transform: none !important; }
}
`}</style>
);
// ---- loading ------------------------------------------------------------
if (!ready) {
return (
<main className="jfc-root min-h-screen w-full bg-[radial-gradient(circle_at_50%_22%,#3a0d12_0%,#1a0507_55%,#0a0203_100%)] p-5 text-amber-50">
{styleTag}
<div className="mx-auto flex min-h-[60vh] max-w-md flex-col items-center justify-center gap-5">
<div className="jfc-spin jfc-sunburst h-40 w-40 rounded-full opacity-60" />
<p className="jfc-display text-sm uppercase tracking-[0.3em] text-amber-200/70">Warming the oven...</p>
</div>
</main>
);
}
// ---- main ---------------------------------------------------------------
const cookieFill = current && current.golden
? { a: '#fff3c4', b: '#e6b53d', c: '#a9761c', stroke: '#7a4f12' }
: { a: '#f4c87a', b: '#d99440', c: '#a9651f', stroke: '#7a440f' };
return (
<main className="jfc-root min-h-screen w-full overflow-x-hidden bg-[radial-gradient(circle_at_50%_18%,#41121a_0%,#1c0608_52%,#0a0203_100%)] px-4 py-6 text-amber-50 sm:px-6">
{styleTag}
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-5">
{/* Header / banner */}
<div className="flex w-full flex-col items-center gap-2 text-center">
<span className="jfc-display jfc-foil text-[10px] font-bold uppercase tracking-[0.42em] sm:text-xs">
The Jank Fortune Cookie
</span>
<div className="flex flex-wrap items-center justify-center gap-2">
<span className="rounded-full border border-amber-300/30 bg-amber-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-200/90">
{persistMode === 'vibes' ? 'Favorites saved' : 'Demo mode · session only'}
</span>
<span className="rounded-full border border-rose-300/20 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/70">
{state.streak > 0 ? `${state.streak}-day streak` : 'Crack daily for a streak'}
</span>
</div>
</div>
{/* Cookie stage -- the hero */}
<section
className={`jfc-stage relative flex w-full flex-col items-center justify-center rounded-[34px] border border-amber-300/15 px-4 pb-7 pt-9 shadow-[0_30px_60px_-30px_rgba(0,0,0,0.9)] ${motionClass} ${phase === 'cracking' ? 'jfc-cracking' : ''} ${phase === 'open' ? 'jfc-open' : ''}`}
>
{/* sunburst backdrop */}
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[34px]">
<div className={`jfc-sunburst absolute left-1/2 top-[42%] h-[140%] w-[140%] -translate-x-1/2 -translate-y-1/2 ${reduceMotion.current ? '' : 'jfc-spin'}`} />
<div className="absolute inset-0 rounded-[34px] bg-[radial-gradient(circle_at_50%_40%,transparent_30%,rgba(10,2,3,0.65)_100%)]" />
</div>
{/* The cookie / crack */}
<div className="relative z-10 flex w-full items-center justify-center" style={{ height: 'clamp(180px, 42vw, 260px)' }}>
<button
type="button"
onClick={crack}
aria-label={phase === 'intact' ? 'Crack the fortune cookie' : 'Crack another fortune'}
className={`group relative flex items-center justify-center outline-none ${phase === 'cracking' ? 'pointer-events-none' : 'cursor-pointer'}`}
>
<svg
viewBox="0 0 220 160"
style={{ height: 'clamp(150px, 38vw, 220px)' }}
className={`jfc-cookie w-auto focus:outline-none ${phase === 'intact' && !reduceMotion.current ? 'jfc-pulse' : ''}`}
role="img"
aria-hidden="true"
>
<defs>
<radialGradient id="jfcGloss" cx="38%" cy="30%" r="80%">
<stop offset="0%" stopColor={cookieFill.a} />
<stop offset="55%" stopColor={cookieFill.b} />
<stop offset="100%" stopColor={cookieFill.c} />
</radialGradient>
</defs>
{/* left half */}
<g className="jfc-half jfc-half-l">
<path
d="M110 24 C70 8 26 26 22 70 C18 112 60 150 110 150 L110 86 C92 86 86 70 96 56 C104 44 110 40 110 24 Z"
fill="url(#jfcGloss)"
stroke={cookieFill.stroke}
strokeWidth="3"
/>
<path d="M62 40 C50 56 48 92 64 124" fill="none" stroke={cookieFill.stroke} strokeWidth="2" opacity="0.35" />
</g>
{/* right half */}
<g className="jfc-half jfc-half-r">
<path
d="M110 24 C150 8 194 26 198 70 C202 112 160 150 110 150 L110 86 C128 86 134 70 124 56 C116 44 110 40 110 24 Z"
fill="url(#jfcGloss)"
stroke={cookieFill.stroke}
strokeWidth="3"
/>
<path d="M158 40 C170 56 172 92 156 124" fill="none" stroke={cookieFill.stroke} strokeWidth="2" opacity="0.35" />
</g>
{/* center seam highlight when intact */}
{phase === 'intact' ? (
<line x1="110" y1="26" x2="110" y2="148" stroke="rgba(255,255,255,0.4)" strokeWidth="2" strokeDasharray="3 5" />
) : null}
</svg>
{/* the emerging paper slip */}
{phase !== 'intact' && current ? (
<div
className="jfc-slip absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
style={{ width: 'clamp(220px, 72vw, 360px)' }}
>
<div
className={`relative rounded-[10px] px-5 py-5 text-center shadow-[0_18px_40px_-12px_rgba(0,0,0,0.8)] ${
current.golden
? 'border-2 border-amber-300/70 text-amber-950'
: 'border border-amber-900/20 text-stone-800'
}`}
style={{
backgroundImage: current.golden
? 'linear-gradient(135deg,#fff6da,#f4dd9a)'
: 'linear-gradient(135deg,#fbf3df,#efe2c4), repeating-linear-gradient(0deg,rgba(120,80,20,0.05) 0 1px,transparent 1px 22px)',
}}
>
{current.golden ? (
<span className="jfc-display mb-1 inline-block rounded-full bg-amber-950 px-3 py-1 text-[9px] font-bold uppercase tracking-[0.22em] text-amber-100">
✦ Golden Fortune · 1 in 12
</span>
) : (
<span className="jfc-display mb-1 inline-block text-[9px] font-semibold uppercase tracking-[0.32em] text-amber-800/70">
Your fortune
</span>
)}
<p
className={`jfc-display font-semibold leading-snug ${current.golden ? 'text-amber-950' : 'text-stone-900'}`}
style={{ fontSize: 'clamp(15px, 4.4vw, 21px)', textWrap: 'balance' }}
>
"{current.text}"
</p>
</div>
</div>
) : null}
</button>
{/* intact invite */}
{phase === 'intact' ? (
<div className="pointer-events-none absolute bottom-1 left-1/2 -translate-x-1/2 text-center">
<p className="jfc-display text-[11px] font-semibold uppercase tracking-[0.34em] text-amber-200/80">Tap to crack</p>
</div>
) : null}
</div>
{/* lucky numbers */}
{phase === 'open' && current ? (
<div className="relative z-10 mt-5 flex w-full flex-col items-center gap-2">
<p className="jfc-display text-[10px] uppercase tracking-[0.3em] text-amber-200/70">Lucky build numbers</p>
<div className="flex flex-wrap items-center justify-center gap-2">
{current.numbers.map((n, i) => (
<span
key={`${n}-${i}`}
className={`jfc-num flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold tabular-nums ${
current.golden
? 'border-amber-300/70 bg-amber-300/15 text-amber-100'
: 'border-amber-200/25 bg-black/30 text-amber-100/90'
}`}
style={{ animationDelay: reduceMotion.current ? '0ms' : `${i * 70}ms` }}
>
{n}
</span>
))}
</div>
</div>
) : null}
</section>
{/* Action bar */}
{phase === 'open' && current ? (
<div className="jfc-rise flex w-full flex-wrap items-center justify-center gap-2">
<button
type="button"
onClick={crack}
className="jfc-display jfc-gold-btn rounded-2xl border border-amber-300/30 px-5 py-3 text-sm font-bold uppercase tracking-[0.16em] text-amber-950 shadow-lg shadow-amber-900/40 transition hover:brightness-110 active:scale-95"
>
Crack another
</button>
<button
type="button"
onClick={toggleFavorite}
aria-pressed={isFavorited}
className={`rounded-2xl border px-4 py-3 text-sm font-bold transition active:scale-95 ${
isFavorited
? 'border-rose-300/50 bg-rose-400/15 text-rose-200'
: 'border-amber-200/25 bg-black/30 text-amber-100/90 hover:border-amber-200/50'
}`}
>
{isFavorited ? '♥ Saved' : '♡ Save'}
</button>
<button
type="button"
onClick={copyCurrent}
className={`rounded-2xl border px-4 py-3 text-sm font-bold transition active:scale-95 ${
copied
? 'border-emerald-300/50 bg-emerald-400/15 text-emerald-200'
: 'border-amber-200/25 bg-black/30 text-amber-100/90 hover:border-amber-200/50'
}`}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
<button
type="button"
onClick={resetCookie}
className="rounded-2xl border border-amber-200/15 bg-transparent px-4 py-3 text-sm font-semibold text-amber-100/60 transition hover:text-amber-100 active:scale-95"
>
New cookie
</button>
</div>
) : (
<button
type="button"
onClick={crack}
className="jfc-display jfc-rise jfc-gold-btn rounded-2xl border border-amber-300/30 px-7 py-3 text-sm font-bold uppercase tracking-[0.18em] text-amber-950 shadow-lg shadow-amber-900/40 transition hover:brightness-110 active:scale-95"
>
Crack the cookie
</button>
)}
{/* Stats strip */}
<div className="grid w-full grid-cols-4 gap-2">
<Stat label="Cracks" value={state.totalCracks} />
<Stat label="Streak" value={state.streak} />
<Stat label="Best" value={state.bestStreak} />
<Stat label="Seen" value={`${seenCount}/${FORTUNES.length}`} />
</div>
{/* Favorites drawer */}
<section className="w-full overflow-hidden rounded-[26px] border border-amber-300/15 bg-black/30">
<button
type="button"
onClick={() => setDrawerOpen((v) => !v)}
className="flex w-full items-center justify-between px-5 py-4 text-left transition hover:bg-amber-300/5"
>
<span className="jfc-display flex items-center gap-2 text-sm font-bold uppercase tracking-[0.18em] text-amber-100">
<span className="text-rose-300">♥</span> Favorites
<span className="rounded-full bg-amber-300/15 px-2 py-0.5 text-[11px] tabular-nums text-amber-200">{state.favorites.length}</span>
</span>
<span className={`text-amber-200/70 transition-transform ${drawerOpen ? 'rotate-180' : ''}`}>▾</span>
</button>
{drawerOpen ? (
<div className="border-t border-amber-300/10 px-4 pb-4 pt-3">
{state.favorites.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-amber-200/20 px-5 py-7 text-center">
<p className="jfc-display text-base font-semibold text-amber-100">No saved fortunes yet.</p>
<p className="mt-1 text-sm text-amber-100/60">Crack a cookie and tap the heart to keep the good ones.</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{state.favorites.map((f) => (
<li
key={f.id}
className={`flex items-start gap-3 rounded-[16px] border px-4 py-3 ${
f.golden ? 'border-amber-300/45 bg-amber-300/10' : 'border-amber-200/15 bg-black/25'
}`}
>
<div className="min-w-0 flex-1">
{f.golden ? (
<span className="jfc-display mb-1 inline-block text-[9px] font-bold uppercase tracking-[0.2em] text-amber-300">✦ Golden</span>
) : null}
<p className="jfc-display break-words text-sm font-medium leading-snug text-amber-50">"{f.text}"</p>
{f.numbers && f.numbers.length ? (
<p className="mt-1 text-[11px] tabular-nums text-amber-200/60">{f.numbers.join(' · ')}</p>
) : null}
</div>
<div className="flex flex-shrink-0 gap-1">
<button
type="button"
onClick={() => copyFavorite(f)}
className="rounded-lg border border-amber-200/20 px-2 py-1 text-[11px] font-semibold text-amber-100/80 transition hover:border-amber-200/50 active:scale-95"
aria-label="Copy favorite"
>
Copy
</button>
<button
type="button"
onClick={() => removeFavorite(f.id)}
className="rounded-lg border border-rose-300/25 px-2 py-1 text-[11px] font-semibold text-rose-200/80 transition hover:border-rose-300/60 active:scale-95"
aria-label="Remove favorite"
>
Remove
</button>
</div>
</li>
))}
</ul>
)}
</div>
) : null}
</section>
<p className="px-2 text-center text-[10px] text-amber-100/35">
For entertainment only -- your build outcomes may vary. {persistMode === 'vibes' ? 'Favorites persist on this device.' : 'Preview mode: favorites last this session only.'}
</p>
</div>
{/* Toast */}
{toast ? (
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-50 flex justify-center px-4">
<div className="jfc-rise rounded-full border border-amber-300/30 bg-[#1c0608]/95 px-4 py-2 text-xs font-semibold text-amber-100 shadow-lg shadow-black/50">
{toast}
</div>
</div>
) : null}
</main>
);
// ---- subcomponents ------------------------------------------------------
function Stat({ label, value }) {
return (
<div className="rounded-[16px] border border-amber-300/12 bg-black/30 px-2 py-3 text-center">
<p className="jfc-display text-lg font-bold tabular-nums text-amber-100">{value}</p>
<p className="mt-0.5 text-[9px] font-semibold uppercase tracking-[0.16em] text-amber-200/55">{label}</p>
</div>
);
}
async function copyFavorite(f) {
const payload = shareText(f.text, f.numbers);
let ok = false;
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(payload);
ok = true;
}
} catch (err) {
ok = false;
}
if (!ok) ok = legacyCopy(payload);
flashToast(ok ? 'Fortune copied' : 'Copy blocked -- copy manually');
}
}
```
[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
### Agent-Accessible State (vibes.ai.*)
State written with the standard `vibes.save` / `vibes.load` methods is private to each user and is **NOT readable by AI agents**. To share state with an AI agent (via the MCP connector), use the `vibes.ai` sub-namespace:
| Method | Description |
|--------|-------------|
| `await vibes.ai.setState(key, value)` | Write agent-readable state. Key is stored internally as `ai/<key>`. |
| `await vibes.ai.getState(key)` | Read agent-readable state. Returns `null` if key absent. |
| `await vibes.ai.listKeys()` | List all agent-readable keys (without the `ai/` prefix). |
**Important rules:**
- Regular `vibes.save()` / `vibes.load()` is **NOT agent-accessible** — use `vibes.ai.*` for state you want agents to read.
- The widget **owner** must enable agent access in **Manage → Agent tab** before any agent can read or write `vibes.ai.*` state.
- Keys are auto-prefixed to `ai/` internally; you supply just the short key (e.g. `'context'`).
```js
vibes.onReady(async () => {
// Write state an AI agent can later read
await vibes.ai.setState('context', { currentLevel: 3, score: 1500 });
// Read it back (same auto-prefix applies)
const ctx = await vibes.ai.getState('context');
// List all agent-accessible keys for this widget
const keys = await vibes.ai.listKeys(); // e.g. ['context']
});
```
### 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. **External JS libraries are allowed and encouraged** — load them via a `<script src>` tag from a reputable CDN (jsDelivr, unpkg, or cdnjs) with a pinned version, or inline the library source. The only exception: do NOT add a script tag for the Vibes SDK itself — it is auto-injected (see Rule 2).
6. **Keep total code under 100KB** — 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/princess-peach/fortune-cookie-fork */