:root{
--fg: #0b0c0f;
--bg: #ffffff;
--muted: rgba(0,0,0,0.58);
--border: rgba(0,0,0,0.16);
--accent: #0f766e; /* CTA */
--item-h: 64px; /* row height for readability */
}
@media (prefers-color-scheme: dark) {
:root{
--fg: #e9eaee;
--bg: #0e0f12;
--muted: rgba(233,234,238,0.65);
--border: rgba(255,255,255,0.18);
--accent: #2ec4b6;
}
}
html, body { height: 100%; }
body{
margin: 0;
font: 18px/1.7 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: var(--fg);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
.title { margin: 0 0 12px; font-size: 30px; line-height: 1.2; font-weight: 800; letter-spacing: 0.2px; }
.status { margin-top: 8px; font-size: 15px; color: var(--muted); }
.card {
background: transparent;
border: 2px solid var(--border);
border-radius: 16px;
padding: 20px;
}
/* Reels layout — bigger & touch-friendly */
.reels { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
@media (max-width: 1024px){ .reels{ grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px){ .reels{ grid-template-columns: 1fr; } }
.reel {
border: 2px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: transparent;
perspective: 800px; /* depth */
box-shadow: 0 10px 24px rgba(0,0,0,0.08);
}
.header {
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 2px solid var(--border);
}
.header strong { font-size: 18px; font-weight: 800; }
.hold { font-size: 14px; color: var(--muted); user-select: none; }
.hold input { width: 18px; height: 18px; vertical-align: middle; margin-left: 6px; }
/* Window shows 5 rows while spinning, 1 row when compact */
.window {
position: relative; overflow: hidden;
height: calc(var(--item-h) * 5);
mask-image: linear-gradient(to bottom, transparent 0, black 12px, black calc(100% - 12px), transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 12px, black calc(100% - 12px), transparent 100%);
transition: height 220ms ease;
transform: translateZ(0);
}
.window.compact { height: var(--item-h); } /* single row when stopped */
.list { position: absolute; left: 0; top: 0; width: 100%; will-change: transform; transform-style: preserve-3d; }
.item {
height: var(--item-h); display: flex; align-items: center; padding: 0 14px;
font-size: 18px; font-weight: 600; color: inherit; background: linear-gradient(180deg, rgba(255,255,255,0.65), transparent);
border-bottom: 1px dashed var(--border);
}
.item.alt { background: transparent; }
.controls { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
.btn {
padding: 12px 16px; border-radius: 14px; border: 2px solid var(--border);
background: transparent; color: inherit; cursor: pointer; font-weight: 700; font-size: 16px;
transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
touch-action: manipulation;
}
.btn:hover { background: rgba(0,0,0,0.06); }
.btn.primary { background: var(--accent); color: #08110f; border-color: rgba(15,118,110,0.9); }
.btn.primary:hover { transform: translateY(-1px); box-shadow: 0 8px 18px rgba(15,118,110,0.25); }
@media (prefers-color-scheme: dark) { .btn.primary { color: #06100e; } }
.winning-header { margin: 0 0 8px; font-size: 24px; font-weight: 900; }
.grid { display: grid; gap: 20px; grid-template-columns: 1fr 1fr; }
@media (max-width: 1024px){ .grid{ grid-template-columns: 1fr; } }
article.work { border: 2px solid var(--border); border-radius: 14px; padding: 16px; }
article.work h3 { margin: 0 0 8px; font-size: 22px; font-weight: 800; }
.meta { font-size: 15px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; }
.cta { margin-top: 10px; }
.motion-blur { filter: blur(0.6px); }
/* Landing bounce (brief overshoot) */
@keyframes landBounce {
0% { transform: translateY(var(--land)); }
60% { transform: translateY(calc(var(--land) + 6px)); }
100% { transform: translateY(var(--land)); }
}
.bounce { animation: landBounce 180ms ease-out forwards; }
.reel.glow .header { box-shadow: 0 0 0 2px var(--accent) inset; }
Skip to content
/* Story Slots — full JS for WordPress page, using your backend payout endpoint */
/* 1) Reels pools */
const POOLS = {
Character: ['Taxi driver','Mime','Cartographer','Night‑shift nurse','Alien','Assassin','Time traveler','Bohemian'],
Situation: ['Gets a cryptic letter','Runs into an old rival','Invents a wildly popular food','Is trapped','Returns to hometown','Witnesses a crime','Goes on a journey'],
Object: ['Lucky penny','Silver quill','Hourglass','Old photograph','Key','Tarot cards','Hidden note','Journal'],
Setting: ['Outskirts farm','Cow shed','Town square','Creek at dawn','Dock at night','Library at noon','Porch in summer']
};
/* 2) Helpers to call your payout endpoint and format text */
async function fetchPayout(tags, mood){
const url = `${window.location.origin}/wp-json/reactink/v1/payout`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags, mood: mood || null })
});
if(!res.ok) throw new Error('Payout fetch failed');
return res.json();
}
function snippetFromExcerpt(text, maxWords = 22){
if(!text) return '';
const words = text.replace(/\s+/g,' ').trim().split(' ');
const cut = words.slice(0, maxWords).join(' ');
const endPunct = /[.?!…]$/.test(cut);
return endPunct ? cut : cut + '…';
}
/* 3) Build reels UI */
const reelsEl = document.getElementById('reels');
const reels = [];
Object.keys(POOLS).forEach((name) => {
const div = document.createElement('div');
div.className = 'reel';
div.innerHTML = `
${POOLS[name].map(v => `
${v}
`).join('')}
`;
reelsEl.appendChild(div);
reels.push({
name,
container: div,
list: div.querySelector(`#list-${name}`),
windowEl: div.querySelector('.window'),
items: POOLS[name].slice(),
hold: false,
current: POOLS[name][0],
itemHeight: parseFloat(getComputedStyle(div.querySelector('.item')).height) || 64
});
});
reelsEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', e => {
const r = reels.find(x => x.name === e.target.dataset.reel);
r.hold = e.target.checked;
});
});
/* 4) Mood + picking */
const status = document.getElementById('status');
const selectionEl = document.getElementById('selection');
const resultsEl = document.getElementById('results');
let moodPref = null; // 'quieter' | 'brighter' | null
function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function weightedPick(items, pref, reelName){
if(!pref) return pick(items);
const weights = items.map(v => {
let w = 1; const s = v.toLowerCase();
if(pref==='quieter'){
if(reelName==='Setting' && (s.includes('dawn')||s.includes('porch')||s.includes('cow shed')||s.includes('creek'))) w+=1.2;
if(reelName==='Situation' && (s.includes('letter')||s.includes('journey'))) w+=0.5;
} else if(pref==='brighter'){
if(reelName==='Setting' && (s.includes('square')||s.includes('library')||s.includes('summer'))) w+=1.2;
if(reelName==='Situation' && (s.includes('invent')||s.includes('returns'))) w+=0.5;
}
return w;
});
const total = weights.reduce((a,b)=>a+b,0);
let r = Math.random()*total;
for(let i=0;i {
const html = r.items.map((v,i) => `${v}
`).join('');
r.list.innerHTML = html + html + html + html; // 4× repeat
r.itemHeight = parseFloat(getComputedStyle(r.list.querySelector('.item')).height) || r.itemHeight;
r.cycleHeight = r.items.length * r.itemHeight;
r.list.style.transform = 'translateY(0px)';
});
}
upgradeListsForSpin();
/* 6) Spin orchestration */
const SPEED_MULT = [0.9, 0.98, 1.03, 1.08];
const START_DELAY = [0, 120, 240, 360];
const BASE_PX_MS = 0.6;
const BLUR_ON = 1.3;
const STOP_JITTER = 280;
const LAND_FLICKER = true;
const SPIN = {
baseLoops: [1.5, 2.1, 2.7, 3.3],
extraRange: [0.15, 0.6],
decelMs: [900, 1150, 1400, 1650],
};
function smoothstep(t){ return t*t*(3 - 2*t); }
function getCurrentOffsetPx(el){
const m = /translateY\((-?\d+(\.\d+)?)px\)/.exec(el.style.transform || '');
return m ? parseFloat(m[1]) : 0;
}
function spinReelSmooth(reel, index, moodPref){
return new Promise(resolve => {
if (reel.hold){ resolve({ reel, choice: reel.current }); return; }
reel.windowEl.classList.remove('compact');
const choice = weightedPick(reel.items, moodPref, reel.name);
const targetIndex = reel.items.indexOf(choice);
reel.current = choice;
const loopsMin = SPIN.baseLoops[index % SPIN.baseLoops.length];
const loopsExtra = SPIN.extraRange[0] + Math.random() * (SPIN.extraRange[1] - SPIN.extraRange[0]);
const constantLoops = loopsMin + loopsExtra;
const decelMs = SPIN.decelMs[index % SPIN.decelMs.length] + Math.floor(Math.random()*STOP_JITTER);
const pxPerMs = BASE_PX_MS * SPEED_MULT[index % SPEED_MULT.length];
const startDelay = START_DELAY[index % START_DELAY.length];
let startOffset = getCurrentOffsetPx(reel.list);
const cycle = reel.cycleHeight;
startOffset = ((startOffset % cycle) + cycle) % cycle;
const phaseAAdvance = constantLoops * cycle;
const phaseBEndOffset = - (targetIndex * reel.itemHeight);
const startTime = performance.now() + startDelay;
let lastNow = startTime;
let phase = 'A';
let phaseBStartOffset = startOffset;
let phaseBStartTime = startTime;
const READABLE_MS = 160;
const READABLE_PX_MS = 0.25;
reel.container.classList.add('glow');
function step(now){
if(now < startTime){ requestAnimationFrame(step); return; }
const dt = Math.max(1, now - lastNow);
lastNow = now;
if(phase === 'A'){
const jitter = (Math.random() - 0.5) * 0.5;
const advance = (pxPerMs + jitter) * dt;
startOffset -= advance;
const wrapped = ((startOffset % (cycle*4)) + (cycle*4)) % (cycle*4);
reel.list.style.transform = `translateY(${wrapped}px)`;
if(pxPerMs > BLUR_ON) reel.list.classList.add('motion-blur'); else reel.list.classList.remove('motion-blur');
if (Math.abs(startOffset) >= phaseAAdvance){
phase = 'B-readable';
phaseBStartOffset = startOffset;
phaseBStartTime = now;
}
requestAnimationFrame(step);
return;
}
if(phase === 'B-readable'){
const tR = (now - phaseBStartTime);
const advance = READABLE_PX_MS * dt;
phaseBStartOffset -= advance;
const wrapped = ((phaseBStartOffset % (cycle*4)) + (cycle*4)) % (cycle*4);
reel.list.style.transform = `translateY(${wrapped}px)`;
reel.list.classList.remove('motion-blur');
if(tR >= READABLE_MS){
phase = 'B-ease';
phaseBStartTime = now;
}
requestAnimationFrame(step);
return;
}
const t = Math.min(1, (now - phaseBStartTime) / decelMs);
const eased = smoothstep(t);
const current = phaseBStartOffset + (phaseBEndOffset - phaseBStartOffset) * eased;
const wrapped = ((current % (cycle*4)) + (cycle*4)) % (cycle*4);
reel.list.style.transform = `translateY(${wrapped}px)`;
if(t > 0.25) reel.list.classList.remove('motion-blur');
if(t < 1){
requestAnimationFrame(step);
} else {
const finalSnap = - (targetIndex * reel.itemHeight);
reel.list.style.setProperty('--land', finalSnap + 'px');
reel.list.style.transform = `translateY(${finalSnap}px)`;
reel.container.classList.remove('glow');
if(LAND_FLICKER){
reel.container.classList.add('glow');
setTimeout(() => reel.container.classList.remove('glow'), 160);
}
reel.list.classList.add('bounce');
setTimeout(() => reel.list.classList.remove('bounce'), 200);
reel.windowEl.classList.add('compact');
resolve({ reel, choice });
}
}
requestAnimationFrame(step);
});
}
async function spinAllSmooth(){
status.textContent = 'Spinning…';
selectionEl.textContent = '';
reels.forEach(r => r.windowEl.classList.remove('compact'));
const spins = reels.map((reel, i) => spinReelSmooth(reel, i, moodPref));
await Promise.all(spins);
finalizeSelection();
}
/* 7) Fingerprint tags for payout */
function fingerprintTags(fp){
const tags = [];
if(fp.Character) tags.push(fp.Character.toLowerCase().split(' ')[0]);
if(fp.Object) tags.push(fp.Object.toLowerCase().split(' ')[0]);
if(fp.Situation){
const s = fp.Situation.toLowerCase();
if(s.includes('letter')) tags.push('letter');
if(s.includes('rival')) tags.push('rival');
if(s.includes('trapped')) tags.push('confinement');
if(s.includes('journey')) tags.push('journey');
if(s.includes('returns')) tags.push('return');
if(s.includes('crime')) tags.push('crime');
}
if(fp.Setting){
const map = { 'Outskirts farm':'farm','Cow shed':'cow-shed','Town square':'square','Creek at dawn':'creek','Dock at night':'dock','Library at noon':'library','Porch in summer':'porch' };
tags.push(map[fp.Setting]);
if(fp.Setting.includes('dawn')) tags.push('dawn');
if(fp.Setting.includes('night')) tags.push('night');
if(fp.Setting.includes('summer')) tags.push('summer');
}
return tags.filter(Boolean);
}
/* 8) Render payout using backend */
async function renderPayout(fp, moodPref){
const tags = fingerprintTags(fp);
try {
const data = await fetchPayout(tags, moodPref);
const two = [data.short, data.long].filter(Boolean);
const html = two.map(w => `
${w.title}
${w.form}
~${w.readMin} min