0
GAMES#02a34801
Good Parts Reader
@GigaChad·deposited 4d ago·updated 4d ago·7 views
GAMES#02a34801
Good Parts Reader
GI
@GigaChad
7Views
0Comments
0Forks
0Saves
SHARE · REMIX
Good Parts Reader — a HTML Games widget by @GigaChad.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widget
vibes.onReadyvibes.savevibes.loadvibes.fetch
- raw fetch(): Raw fetch() detected. Prefer vibes.fetch for proxied requests.
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "Good Parts Reader" by @GigaChad]
Source: https://itjustvibes.com/GigaChad/good-parts-reader
Type: HTML
--- SOURCE CODE ---
```html
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Good Parts Reader</title>
<style>:root{--bg:#09090b;--bg2:#11111a;--text:#f4f4f5;--muted:#a1a1aa;--accent:#f43f5e;--accent2:#fb7185;--line:rgba(255,255,255,.10);--line2:rgba(255,255,255,.06);--fontSize:44px;--lineHeight:1.55;--letterSpacing:0em;--serif:"Iowan Old Style","Charter","Palatino Linotype","Palatino","Georgia",serif;--ui:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Helvetica,Arial,sans-serif}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--text);font-family:var(--ui);-webkit-font-smoothing:antialiased}body{min-height:100vh;background:radial-gradient(1200px 600px at 90% -10%,color-mix(in oklab,var(--accent) 22%,transparent),transparent 60%),radial-gradient(900px 500px at -10% 110%,color-mix(in oklab,var(--accent) 14%,transparent),transparent 60%),var(--bg);overflow-x:hidden}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;cursor:pointer;color:var(--text)}.wrap{max-width:1180px;margin:0 auto;padding:18px 18px 96px;position:relative;z-index:1}.brand{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:6px 4px 22px}.logo{display:flex;align-items:center;gap:10px}.logo .dot{width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 18px var(--accent)}.logo b{font-weight:800;letter-spacing:-.01em}.logo span{color:var(--muted);font-size:12px;letter-spacing:.12em;text-transform:uppercase;display:none}.brand-act{display:flex;gap:6px;flex-wrap:wrap}.icobtn{padding:9px 11px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.03);font-size:13px;display:inline-flex;align-items:center;gap:6px;transition:.18s;min-height:36px}.icobtn:hover{background:rgba(255,255,255,.07)}.hero{position:relative;border:1px solid var(--line);border-radius:28px;padding:28px;background:radial-gradient(600px 280px at 80% -10%,color-mix(in oklab,var(--accent) 30%,transparent),transparent 60%),linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,.015));box-shadow:0 30px 80px -30px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.05);overflow:hidden}.hero::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025) 0 1px,transparent 1px 3px);pointer-events:none;mix-blend-mode:overlay;opacity:.5}.eyebrow{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted);margin:0 0 12px}.h1{font-family:var(--serif);font-size:clamp(32px,5vw,54px);line-height:1.04;letter-spacing:-.02em;margin:0 0 10px;font-weight:600}.h1 em{font-style:italic;color:var(--accent2)}.lede{color:#cfcfd6;max-width:60ch;margin:0 0 22px;font-size:15px;line-height:1.55}.hero-row{display:flex;gap:10px;flex-wrap:wrap}.bigbtn{padding:13px 22px;border-radius:14px;background:linear-gradient(180deg,var(--accent),color-mix(in oklab,var(--accent) 65%,#000));color:#fff;font-weight:700;font-size:15px;border:0;box-shadow:0 14px 38px -12px var(--accent);transition:.18s;min-height:46px}.bigbtn:hover{transform:translateY(-1px)}.ghost{padding:13px 18px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.03);min-height:46px}.section-title{margin:30px 4px 14px;display:flex;justify-content:space-between;align-items:baseline}.section-title h2{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em;margin:0}.section-title small{color:var(--muted);font-size:11px;letter-spacing:.14em;text-transform:uppercase}.moods{display:grid;gap:14px;grid-template-columns:repeat(4,1fr)}.mood{position:relative;padding:20px;border-radius:22px;border:1px solid var(--line);overflow:hidden;text-align:left;min-height:130px;display:flex;flex-direction:column;justify-content:space-between;transition:transform .22s,border-color .22s,box-shadow .22s;cursor:pointer;color:#fff}.mood:hover{transform:translateY(-2px);border-color:rgba(255,255,255,.18);box-shadow:0 18px 50px -20px rgba(0,0,0,.7)}.mood::before{content:"";position:absolute;inset:0;opacity:.88;background:var(--mg,linear-gradient(135deg,#3b1d3b,#0c0a14));z-index:-1}.mood::after{content:"";position:absolute;inset:0;background:radial-gradient(120% 80% at 0% 100%,rgba(0,0,0,.45),transparent 60%);z-index:-1}.mood h3{font-family:var(--serif);font-weight:600;font-size:21px;margin:0;letter-spacing:-.01em}.mood p{margin:6px 0 0;font-size:12.5px;color:rgba(255,255,255,.82);line-height:1.4}.mood .glyph{position:absolute;top:14px;right:14px;width:30px;height:30px;border-radius:50%;background:rgba(255,255,255,.18);display:grid;place-items:center;font-family:var(--serif);font-style:italic;font-size:16px;font-weight:600}.mg-whimsical{--mg:linear-gradient(135deg,#7c3aed 0%,#ec4899 60%,#fcd34d 110%)}.mg-gothic{--mg:linear-gradient(135deg,#1f0a1c 0%,#3b0764 55%,#7f1d1d 110%)}.mg-adventure{--mg:linear-gradient(135deg,#0e7490 0%,#0891b2 55%,#f59e0b 110%)}.mg-mystery{--mg:linear-gradient(135deg,#0b1220 0%,#1e293b 55%,#475569 110%)}.mg-romance{--mg:linear-gradient(135deg,#9d174d 0%,#be185d 55%,#fda4af 110%)}.mg-nature{--mg:linear-gradient(135deg,#14532d 0%,#65a30d 55%,#facc15 110%)}.mg-dialogue{--mg:linear-gradient(135deg,#78350f 0%,#b45309 55%,#fcd34d 110%)}.mg-random{--mg:conic-gradient(from 200deg at 30% 70%,#f43f5e,#a855f7,#38bdf8,#22d3ee,#facc15,#f43f5e)}.reader{border:1px solid var(--line);border-radius:28px;padding:22px;background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(255,255,255,.012));box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 30px 80px -40px rgba(0,0,0,.6)}.meta{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;margin-bottom:14px}.meta .who{font-family:var(--serif);min-width:0}.meta .who h2{margin:0;font-size:22px;font-weight:600;line-height:1.15;letter-spacing:-.01em}.meta .who p{margin:4px 0 0;color:var(--muted);font-size:13px;font-family:var(--ui)}.meta .right{display:flex;gap:6px;flex-wrap:wrap}.badge{display:inline-block;padding:3px 9px;border-radius:999px;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:rgba(255,255,255,.85);background:rgba(255,255,255,.08);border:1px solid var(--line);font-family:var(--ui)}.modes{display:flex;gap:4px;padding:4px;border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.25);width:fit-content;margin:0 0 18px;flex-wrap:wrap}.modes button{padding:8px 12px;border-radius:10px;font-size:12.5px;color:var(--muted);transition:.18s}.modes button.on{background:var(--accent);color:#fff;box-shadow:0 6px 18px -8px var(--accent)}.modes button:hover:not(.on){color:var(--text)}.lens{position:relative;min-height:280px;display:grid;place-items:center;padding:38px 14px;border-radius:20px;background:radial-gradient(60% 60% at 50% 50%,rgba(255,255,255,.04),transparent 70%);border:1px solid var(--line2);overflow:hidden}.lens .rule{position:absolute;left:50%;top:50%;width:2px;height:14px;background:var(--accent);transform:translate(-50%,-66px);opacity:.55;border-radius:2px}.lens .rule.b{transform:translate(-50%,52px)}.word{font-family:var(--serif);font-size:var(--fontSize);line-height:var(--lineHeight);letter-spacing:var(--letterSpacing);font-weight:600;text-align:center;color:var(--text);white-space:nowrap}.focal{color:var(--accent);font-weight:800;border-bottom:3px solid color-mix(in oklab,var(--accent) 70%,transparent);padding-bottom:1px}.ctx{color:var(--muted);font-family:var(--serif);font-size:calc(var(--fontSize) * .40);opacity:.55;line-height:1.4;margin:6px 0;max-width:92%;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.prog{height:4px;background:rgba(255,255,255,.07);border-radius:999px;overflow:hidden;margin:16px 0 4px}.prog .bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:width .18s}.progmeta{display:flex;justify-content:space-between;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;margin-bottom:14px}.prose{font-family:var(--serif);font-size:var(--fontSize);line-height:var(--lineHeight);letter-spacing:var(--letterSpacing);max-width:62ch;margin:0 auto;padding:18px 4px;color:var(--text)}.prose p{margin:0 0 1em}.focus-line{background:color-mix(in oklab,var(--accent) 14%,transparent);border-radius:6px;padding:1px 4px;box-shadow:inset 0 0 0 1px color-mix(in oklab,var(--accent) 22%,transparent)}.ctrls{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-top:18px}.ctrl{padding:11px 14px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.03);font-size:14px;transition:.18s;display:inline-flex;align-items:center;gap:7px;min-height:44px}.ctrl:hover{background:rgba(255,255,255,.07)}.ctrl.play{background:linear-gradient(180deg,var(--accent),color-mix(in oklab,var(--accent) 65%,#000));border-color:transparent;color:#fff;padding:11px 22px;box-shadow:0 10px 26px -10px var(--accent)}.sliders{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:18px}.slider{padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.025)}.slider .row{display:flex;justify-content:space-between;align-items:baseline;font-size:12.5px;color:var(--muted);margin-bottom:6px}.slider .row b{color:var(--text);font-weight:600;font-variant-numeric:tabular-nums;font-size:13px}input[type=range]{-webkit-appearance:none;width:100%;height:24px;background:transparent;margin:0}input[type=range]:focus{outline:none}input[type=range]::-webkit-slider-runnable-track{height:5px;background:rgba(255,255,255,.10);border-radius:999px}input[type=range]::-moz-range-track{height:5px;background:rgba(255,255,255,.10);border-radius:999px}input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;height:18px;width:18px;border-radius:50%;background:var(--accent);margin-top:-7px;box-shadow:0 4px 12px rgba(0,0,0,.5),0 0 0 4px color-mix(in oklab,var(--accent) 25%,transparent)}input[type=range]::-moz-range-thumb{height:18px;width:18px;border:0;border-radius:50%;background:var(--accent)}.drawer{position:fixed;inset:auto 0 0 0;max-height:84vh;overflow:auto;background:color-mix(in oklab,var(--bg2) 94%,#000);border-top:1px solid var(--line);border-radius:22px 22px 0 0;transform:translateY(110%);transition:transform .32s cubic-bezier(.2,.8,.2,1);z-index:50;padding:18px 18px 28px;box-shadow:0 -30px 80px -20px rgba(0,0,0,.7)}.drawer.open{transform:translateY(0)}.drawer h3{margin:6px 4px 12px;font-family:var(--serif);font-weight:600;font-size:20px}.drawer .grp{display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:10px}.drawer .full{grid-column:1/-1}.drawer .field{display:block;padding:10px 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.025)}.drawer .field .row{display:flex;justify-content:space-between;font-size:12.5px;color:var(--muted);margin-bottom:6px}.drawer .field b{color:var(--text);font-weight:600;font-size:13px}.scrim{position:fixed;inset:0;background:rgba(0,0,0,.55);opacity:0;pointer-events:none;transition:opacity .22s;z-index:49}.scrim.show{opacity:1;pointer-events:auto}.close-drawer{position:absolute;top:14px;right:14px;padding:8px 10px;border-radius:10px;border:1px solid var(--line);background:rgba(255,255,255,.05)}.themes{display:flex;gap:8px;flex-wrap:wrap}.theme{padding:7px 11px;border-radius:999px;border:1px solid var(--line);font-size:12.5px;background:rgba(255,255,255,.03);display:inline-flex;align-items:center;gap:7px;cursor:pointer}.theme.on{border-color:var(--accent)}.theme .sw{width:14px;height:14px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.4)}.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;flex-wrap:wrap}.seg button{padding:7px 11px;font-size:12px;color:var(--muted)}.seg button.on{background:var(--accent);color:#fff}.colorpick{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px}.colorpick input[type=color]{width:30px;height:30px;border:1px solid var(--line);border-radius:8px;padding:0;background:transparent;cursor:pointer}.colorpick label{font-size:11px;color:var(--muted);display:flex;flex-direction:column;align-items:center;gap:3px}.complete{text-align:center;padding:18px 8px 4px}.complete h2{font-family:var(--serif);font-weight:600;font-size:28px;margin:6px 0 6px;letter-spacing:-.01em}.complete .sub{color:var(--muted);margin:0 0 18px;font-size:14px}.stat-row{display:flex;justify-content:center;gap:14px;margin:14px 0 22px;flex-wrap:wrap}.stat{padding:14px 18px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.03);min-width:110px}.stat .n{font-family:var(--serif);font-size:28px;font-weight:600;letter-spacing:-.01em}.stat .l{font-size:10.5px;color:var(--muted);letter-spacing:.16em;text-transform:uppercase;margin-top:2px}.rate-lab{font-size:13px;color:var(--muted);margin:14px 0 8px}.rating{display:flex;gap:5px;justify-content:center;flex-wrap:wrap;margin:0 0 4px}.rating button{width:36px;height:36px;border-radius:10px;border:1px solid var(--line);background:rgba(255,255,255,.03);font-size:13px}.rating button.on{background:var(--accent);color:#fff;border-color:transparent}.compcheck{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}.compcheck button{padding:8px 14px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.03);font-size:13px}.compcheck button.on{background:var(--accent);border-color:transparent;color:#fff}.hist-item{display:flex;justify-content:space-between;gap:12px;padding:14px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.02);margin-bottom:8px;flex-wrap:wrap;align-items:center}.hist-item .info{min-width:0;flex:1}.hist-item h4{margin:0;font-family:var(--serif);font-weight:600;font-size:16px;letter-spacing:-.01em}.hist-item p{margin:3px 0 0;color:var(--muted);font-size:12px}.hist-item .nums{color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.loading{display:flex;justify-content:center;align-items:center;gap:10px;padding:60px;color:var(--muted)}.spin{width:18px;height:18px;border-radius:50%;border:2px solid rgba(255,255,255,.15);border-top-color:var(--accent);animation:s 1s linear infinite}@keyframes s{to{transform:rotate(360deg)}}.toast{position:fixed;left:50%;bottom:18px;transform:translate(-50%,18px);background:rgba(20,20,28,.95);border:1px solid var(--line);padding:10px 16px;border-radius:999px;font-size:13px;opacity:0;pointer-events:none;transition:.25s;z-index:60;backdrop-filter:blur(10px);max-width:90vw;text-align:center}.toast.show{opacity:1;transform:translate(-50%,0)}.err{padding:12px 14px;border-radius:14px;background:rgba(244,63,94,.10);border:1px solid rgba(244,63,94,.30);color:#fecaca;margin:10px 0;font-size:13px}.empty{color:var(--muted);font-size:13px;text-align:center;padding:24px}@media (min-width:520px){.logo span{display:inline}}@media (max-width:880px){.moods{grid-template-columns:repeat(2,1fr)}}@media (max-width:560px){.wrap{padding:12px 12px 92px}.moods{gap:10px}.mood{padding:16px;min-height:108px;border-radius:18px}.mood h3{font-size:18px}.hero{padding:22px;border-radius:22px}.sliders{grid-template-columns:1fr}.reader{padding:14px;border-radius:22px}.lens{min-height:220px;padding:28px 8px}.word{font-size:min(var(--fontSize),52px)}.ctrl{padding:10px 12px;font-size:13px}.drawer .grp{grid-template-columns:1fr}.stat{min-width:88px;padding:12px 14px}.stat .n{font-size:24px}}@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}.reduced-motion *,.reduced-motion *::before,.reduced-motion *::after{animation:none!important;transition:none!important}.hl-none .focal{color:inherit;border-bottom:0;font-weight:600}.hl-bold .focal{color:inherit;border-bottom:0;font-weight:900}.hl-color .focal{color:var(--accent);border-bottom:0;font-weight:700}.hl-underline .focal{color:inherit;border-bottom:3px solid var(--accent);font-weight:600}.hl-box .focal{color:inherit;border-bottom:0;background:color-mix(in oklab,var(--accent) 22%,transparent);padding:0 4px;border-radius:5px;font-weight:700}</style>
</head><body><div id="app" class="wrap"></div><div id="scrim" class="scrim"></div><div id="drawer" class="drawer" role="dialog" aria-label="Settings"></div><div id="toast" class="toast" role="status" aria-live="polite"></div>
<script>"use strict";
if (typeof window.vibes === "undefined"){
const mem = new Map();
window.vibes ={
_shim: true,
onReady(fn){Promise.resolve().then(fn);},
async save(k,v){mem.set(k,JSON.parse(JSON.stringify(v)));},
async load(k){return mem.has(k) ? JSON.parse(JSON.stringify(mem.get(k))): null;},
async fetch(url,opts){return await fetch(url,opts);}
};
}
const defaultSettings ={
mode:"context",wpm:325,fontSize:44,lineHeight:1.55,letterSpacing:0,
theme:"midnight",textColor:"#f4f4f5",bgColor:"#09090b",accentColor:"#f43f5e",
highlightStyle:"underline",chunkSize:1,contextWords:3,punctuationPause:1.6,reducedMotion:false
};
const themes ={
midnight:{label:"Midnight",bgColor:"#09090b",textColor:"#f4f4f5",accentColor:"#f43f5e"},
warmPaper:{label:"Warm Paper",bgColor:"#f4ead7",textColor:"#241c15",accentColor:"#b45309"},
calmBlue:{label:"Calm Blue",bgColor:"#0f172a",textColor:"#dbeafe",accentColor:"#38bdf8"},
highContrast:{label:"High Contrast",bgColor:"#000000",textColor:"#ffffff",accentColor:"#ffff00"},
softGreen:{label:"Soft Green",bgColor:"#101812",textColor:"#e7f5e8",accentColor:"#86efac"}
};
const moods = [
{id:"whimsical",title:"Whimsical",desc:"Wonderlands & wishes",q:"alice wonderland wizard oz fairy"},
{id:"gothic",title:"Gothic",desc:"Castles & shadows",q:"dracula frankenstein ghost"},
{id:"adventure",title:"Adventure",desc:"Seas & strange lands",q:"treasure island verne sea"},
{id:"mystery",title:"Mystery",desc:"Detectives & doubt",q:"sherlock holmes detective"},
{id:"romance",title:"Romance",desc:"Letters & longing",q:"austen bronte romance"},
{id:"nature",title:"Nature",desc:"Forests & gardens",q:"garden forest nature"},
{id:"dialogue",title:"Dialogue",desc:"Wit & repartee",q:"comedy manners dialogue"},
{id:"random",title:"Surprise",desc:"A random classic",q:"classic"}
];
const fallbackPassages = [
{title:"Alice's Adventures in Wonderland",author:"Lewis Carroll",mood:"whimsical",source:"inline",
text:'So she was considering, in her own mind, whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her. There was nothing so very remarkable in that; nor did Alice think it so very much out of the way to hear the Rabbit say to itself, "Oh dear! Oh dear! I shall be too late!'},
{title:"The Adventures of Sherlock Holmes",author:"Arthur Conan Doyle",mood:"mystery",source:"inline",
text:'To Sherlock Holmes she is always the woman. I have seldom heard him mention her under any other name. In his eyes she eclipses and predominates the whole of her sex. It was not that he felt any emotion akin to love for Irene Adler. All emotions, and that one particularly, were abhorrent to his cold, precise but admirably balanced mind.'},
{title:"Dracula",author:"Bram Stoker",mood:"gothic",source:"inline",
text:'Soon we were hemmed in with trees, which in places arched right over the roadway till we passed as through a tunnel; and again great frowning rocks guarded us boldly on either side. Though we were in shelter, we could hear the rising wind, for it moaned and whistled through the rocks, and the branches of the trees crashed together as we swept along.'}
];
const state ={
screen:"home",loading:false,error:"",settings:null,
history:[],savedPassages:[],currentPassage:null,
words:[],index:0,playing:false,timer:null,
startedAt:0,elapsedMs:0,
pendingComfort:null,pendingComprehension:null,drawerOpen:false
};
async function safeLoad(key,fb){
try{const v = await window.vibes.load(key);return v ?? fb;}
catch(e){console.warn("Load failed",key,e&&e.message);return fb;}
}
async function safeSave(key,v){
try{await window.vibes.save(key,v);}
catch(e){console.warn("Save failed",key,e&&e.message);}
}
let saveTimer;
function debouncedSaveSettings(){
clearTimeout(saveTimer);
saveTimer = setTimeout(()=>safeSave("reader_settings",state.settings),500);
}
async function fetchOrProxy(url,options){
options = options ||{};
try{
const r = await fetch(url,options);
if (r.ok) return r;
throw new Error("HTTP "+r.status);
} catch(err){
console.warn("Direct fetch failed, using proxy:",err.message);
}
try{
return await window.vibes.fetch(url,{
method: options.method || "GET",
headers: options.headers ||{},
body: options.body || null,
timeout: 12000
});
} catch(e){
console.error("Proxy fetch failed:",e && (e.code || e.message));
throw e;
}
}
async function searchBooks(moodId){
const m = moods.find(x=>x.id===moodId) || moods[moods.length-1];
const q = encodeURIComponent(m.q);
const url = `https://gutendex.com/books/?search=${q}&languages=en©right=false`;
const res = await fetchOrProxy(url);
const data = await res.json();
return (data && data.results) || [];
}
function getTextUrl(book){
const f = (book && book.formats) ||{};
return f["text/plain; charset=utf-8"] || f["text/plain; charset=us-ascii"] || f["text/plain"] ||
Object.values(f).find(u=>typeof u==="string"&&u.includes(".txt")&&!u.includes(".zip"));
}
function cleanGutenbergText(raw){
let t = raw;
const startMarkers = ["*** START OF THE PROJECT GUTENBERG EBOOK","*** START OF THIS PROJECT GUTENBERG EBOOK","***START OF THE PROJECT GUTENBERG"];
const endMarkers = ["*** END OF THE PROJECT GUTENBERG EBOOK","*** END OF THIS PROJECT GUTENBERG EBOOK","***END OF THE PROJECT GUTENBERG"];
for (const m of startMarkers){const i = t.indexOf(m);if (i!==-1){const nl = t.indexOf("\n",i);t = t.slice(nl===-1?i+m.length:nl+1);break;}}
for (const m of endMarkers){const i = t.indexOf(m);if (i!==-1){t = t.slice(0,i);break;}}
return t.replace(/\r/g,"").replace(/[\t]+/g," ").replace(/\n{3,}/g,"\n\n").trim();
}
function countWords(text){return text.trim().split(/\s+/).filter(Boolean).length;}
function makeCandidatePassages(cleanText){
const paragraphs = cleanText.split(/\n\s*\n/).map(p=>p.replace(/\s+/g," ").trim()).filter(p=>p.length>40);
const out = [];
for (let i=0;i<paragraphs.length;i++){
let block = "";
for (let j=i;j<Math.min(i+5,paragraphs.length);j++){
block += (block?"\n\n":"") + paragraphs[j];
const wc = countWords(block);
if (wc>=120 && wc<=700) out.push(block);
if (wc>700) break;
}
}
return out;
}
function scorePassage(text){
const wc = countWords(text);
if (wc<120 || wc>700) return -999;
const lower = text.toLowerCase();
let s = 0;
const vivid = ["dark","bright","strange","suddenly","door","garden","sea","storm","night","light","shadow","voice","cried","whispered","looked","turned","opened","castle","forest","room","road","fire","cold","window","heart","river","mountain","star","moon","silence","laughed","trembled","silver","golden"];
vivid.forEach(w=>{if (lower.includes(w)) s+=2;});
if (text.includes('"')) s += 8;
if (/[!?]/.test(text)) s += 3;
if (/\b[A-Z][a-z]+\s+(said|cried|whispered|asked|replied|answered)\b/.test(text)) s += 5;
if (/chapter\s+[ivxlcdm\d]+/i.test(text)) s -= 12;
if (/contents|illustration|preface|translator|footnote|copyright|produced by|transcrib/i.test(text)) s -= 12;
if (/\b[A-Z]{4,}\b/.test(text)) s -= 2;
if (wc>=200 && wc<=450) s += 4;
return s;
}
function pickGoodPassage(cleanText){
const cands = makeCandidatePassages(cleanText);
if (!cands.length){
return cleanText.split(/\s+/).slice(0,350).join(" ");
}
const scored = cands.map(t=>({t,s:scorePassage(t)})).filter(x=>x.s>-100).sort((a,b)=>b.s-a.s);
if (!scored.length) return cands[0];
const top = scored.slice(0,Math.min(8,scored.length));
return top[Math.floor(Math.random()*top.length)].t;
}
function estDifficulty(text){
const wc = countWords(text);
const sentences = text.split(/[.!?]+/).filter(s=>s.trim().length>0).length || 1;
const avg = wc/sentences;
if (avg<14) return "Easy";
if (avg<22) return "Medium";
return "Dense";
}
function prepareWords(text){
state.words = (text||"").replace(/\n+/g," ").split(/\s+/).filter(Boolean);
state.index = 0;
}
function getWordDelay(word){
const base = 60000 / state.settings.wpm;
let m = 1;
if (/[.!?]["']?$/.test(word)) m *= state.settings.punctuationPause;
else if (/[,;:]["']?$/.test(word)) m *= 1.25;
if (word.length > 9) m *= 1.15;
return base * m;
}
function scheduleNextWord(){
clearTimeout(state.timer);
if (!state.playing) return;
if (state.index >= state.words.length - 1){ completeSession(); return; }
const w = state.words[state.index] || "";
const d = getWordDelay(w);
state.timer = setTimeout(()=>{
state.index++;
state.elapsedMs += d;
paintReaderTick();
scheduleNextWord();
}, d);
}
function play(){
if (!state.words.length) return;
state.playing = true;
if (!state.startedAt) state.startedAt = Date.now();
scheduleNextWord();
paintReaderTick();
}
function pause(){
state.playing = false;
clearTimeout(state.timer);
paintReaderTick();
}
function restart(){ pause(); state.index = 0; state.elapsedMs = 0; state.startedAt = 0; renderApp(); }
function back5(){ state.index = Math.max(0, state.index - 5); paintReaderTick(); }
function fwd5(){ state.index = Math.min(state.words.length-1, state.index + 5); paintReaderTick(); }
function backSentence(){
let i = state.index - 1;
while (i > 0 && !/[.!?]["']?$/.test(state.words[i])) i--;
state.index = Math.max(0,i);
paintReaderTick();
}
function applyTheme(){
const s = state.settings || defaultSettings;
const r = document.documentElement.style;
r.setProperty("--bg",s.bgColor);
r.setProperty("--text",s.textColor);
r.setProperty("--accent",s.accentColor);
r.setProperty("--fontSize",s.fontSize + "px");
r.setProperty("--lineHeight",s.lineHeight);
r.setProperty("--letterSpacing",s.letterSpacing + "em");
document.body.classList.toggle("reduced-motion",!!s.reducedMotion);
document.body.className = document.body.className.replace(/\bhl-\w+/g,"").trim();
document.body.classList.add("hl-" + (s.highlightStyle || "underline"));
}
async function loadFromMood(moodId){
state.loading = true;
state.error = "";
state.screen = "loading";
renderApp();
try{
const books = await searchBooks(moodId);
const usable = books.filter(b => getTextUrl(b)).slice(0,12);
if (!usable.length) throw new Error("No books with plain text for this mood.");
const ordered = usable.sort(()=>Math.random()-0.5);
let passage = null,picked = null;
for (const book of ordered.slice(0,4)){
try{
const txtUrl = getTextUrl(book);
const r = await fetchOrProxy(txtUrl);
const raw = await r.text();
const cleaned = cleanGutenbergText(raw);
const p = pickGoodPassage(cleaned);
if (p && countWords(p) >= 80){passage = p;picked = book;break;}
} catch(e){}
}
if (!passage){
const fb = pickFallback(moodId);
state.currentPassage = Object.assign({},fb,{mood: moodId,addedAt: Date.now()});
state.error = "Couldn't fetch a fresh book -- showing a classic snippet instead.";
} else{
state.currentPassage ={
title: picked.title || "Untitled",
author: (picked.authors && picked.authors[0] && picked.authors[0].name) || "Unknown",
mood: moodId,
source: "gutenberg",
gutenbergId: picked.id,
text: passage,
addedAt: Date.now()
};
}
prepareWords(state.currentPassage.text);
state.elapsedMs = 0;state.startedAt = 0;state.playing = false;state.index = 0;
state.screen = "reader";
state.loading = false;
const toSave = Object.assign({},state.currentPassage);
if (toSave.text && toSave.text.length > 5000) toSave.text = toSave.text.slice(0,5000);
await safeSave("current_passage",toSave);
renderApp();
} catch (err){
console.error(err);
const fb = pickFallback(moodId);
state.currentPassage = Object.assign({},fb,{mood: moodId,addedAt: Date.now()});
prepareWords(state.currentPassage.text);
state.error = "Couldn't reach Project Gutenberg -- showing a classic snippet instead.";
state.screen = "reader";
state.loading = false;
await safeSave("current_passage",state.currentPassage);
renderApp();
}
}
function pickFallback(moodId){
const m = fallbackPassages.find(p=>p.mood===moodId);
return m || fallbackPassages[Math.floor(Math.random()*fallbackPassages.length)];
}
function $(id){return document.getElementById(id);}
function escHtml(s){return String(s==null?"":s).replace(/[&<>"']/g, c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));}
function fmtTime(ms){const t=Math.max(0,Math.round(ms/1000));const m=Math.floor(t/60);const s=t%60;return m+":"+(s<10?"0":"")+s;}
function wpmLabel(w){if (w<=225) return "Relaxed";if (w<=325) return "Normal";if (w<=450) return "Quick";if (w<=650) return "Sprint";return "Insane";}
function renderApp(){
const a = $("app");
if (state.screen === "loading") a.innerHTML = renderLoading();
else if (state.screen === "reader") a.innerHTML = renderShell() + renderReader();
else if (state.screen === "complete") a.innerHTML = renderShell() + renderComplete();
else if (state.screen === "history") a.innerHTML = renderShell() + renderHistory();
else a.innerHTML = renderShell() + renderHome();
bindEvents();
paintReaderTick();
}
function renderShell(){
const onPlatform = !window.vibes._shim;
return `<div class="brand">
<div class="logo"><span class="dot"></span><b>Good Parts Reader</b><span>· Read the good parts first</span></div>
<div class="brand-act">
${state.screen!=="home"?'<button class="icobtn" data-act="home">← Home</button>':''}
<button class="icobtn" data-act="history">History</button>
<button class="icobtn" data-act="opensettings">Settings</button>
</div>
</div>`;
}
function renderLoading(){
return renderShell() + `<div class="loading"><div class="spin"></div><div>Finding a good part...</div></div>`;
}
function renderHome(){
const cp = state.currentPassage;
const heroBody = cp
? `<p class="eyebrow">Where you left off</p>
<h1 class="h1">${escHtml(cp.title)}<br><em>${escHtml(cp.author)}</em></h1>
<p class="lede">${escHtml(snippetPreview(cp.text, 220))}</p>
<div class="hero-row">
<button class="bigbtn" data-act="goto-reader">Resume reading →</button>
<button class="ghost" data-act="newpassage" data-mood="${escHtml(cp.mood||'random')}">New passage</button>
</div>`
: `<p class="eyebrow">A reading playground</p>
<h1 class="h1">Read the <em>good parts</em><br>of the classics, your way.</h1>
<p class="lede">A configurable reading lab built on public-domain books. Pick a mood, get a memorable passage, tune the speed and focus until reading feels right.</p>
<div class="hero-row">
<button class="bigbtn" data-act="newpassage" data-mood="random">Surprise me →</button>
<button class="ghost" data-act="opensettings">Tune the reader</button>
</div>`;
return `<section class="hero">${heroBody}</section>
${state.error?`<div class="err">${escHtml(state.error)}</div>`:""}
<div class="section-title"><h2>Choose a mood</h2><small>Powered by Project Gutenberg</small></div>
<div class="moods">
${moods.map(m=>`<button class="mood mg-${m.id}" data-act="newpassage" data-mood="${m.id}">
<div><h3>${escHtml(m.title)}</h3><p>${escHtml(m.desc)}</p></div>
<div class="glyph">${m.title[0]}</div>
</button>`).join("")}
</div>`;
}
function snippetPreview(text,max){
const s = (text||"").replace(/\s+/g," ").trim();
return s.length > max ? s.slice(0,max).replace(/[\s,.;:!?-]+\S*$/,"") + "...": s;
}
function renderReader(){
const cp = state.currentPassage;
if (!cp) return `<div class="empty">No passage loaded.</div>`;
const s = state.settings;
const wc = state.words.length;
const pct = wc ? Math.round((state.index/Math.max(1,wc-1))*100): 0;
const modeBtns = [["normal","Normal"],["focus","Focus"],["sprint","Sprint"],["context","Context"]]
.map(([id,lab])=>`<button data-act="setmode" data-m="${id}" class="${s.mode===id?'on':''}">${lab}</button>`).join("");
const bodyView = s.mode==="normal" ? renderProse(cp.text,false)
: s.mode==="focus" ? renderProse(cp.text,true)
: renderLens();
const diff = estDifficulty(cp.text);
const estReadSec = Math.round((wc/Math.max(150,s.wpm))*60);
return `${state.error?`<div class="err">${escHtml(state.error)}</div>`:""}
<section class="reader">
<div class="meta">
<div class="who">
<h2>${escHtml(cp.title)}</h2>
<p>${escHtml(cp.author)} · ${wc} words · ~${fmtTime(estReadSec*1000)} at ${s.wpm} wpm</p>
</div>
<div class="right">
<span class="badge">${escHtml(cp.mood||'random')}</span>
<span class="badge">${diff}</span>
<button class="icobtn" data-act="bookmark" title="Save passage">★ Save</button>
</div>
</div>
<div class="modes" role="tablist">${modeBtns}</div>
${bodyView}
<div class="prog"><div class="bar" id="progbar" style="width:${pct}%"></div></div>
<div class="progmeta"><span id="progtext">Word ${Math.min(state.index+1,wc)} / ${wc}</span><span>${pct}%</span></div>
<div class="ctrls">
<button class="ctrl" data-act="backsent" title="Back one sentence">⤺ Sent.</button>
<button class="ctrl" data-act="back5" title="Back 5 words (←)">‹‹ 5</button>
<button class="ctrl play" data-act="toggle" id="playBtn">${state.playing?'❚❚ Pause':'▶ Play'}</button>
<button class="ctrl" data-act="fwd5" title="Forward 5 words (→)">5 ››</button>
<button class="ctrl" data-act="restart" title="Restart (R)">↻ Restart</button>
<button class="ctrl" data-act="newpassage" data-mood="${escHtml(cp.mood||'random')}" title="New passage (N)">↪ New</button>
</div>
<div class="sliders">
<div class="slider"><div class="row"><span>Reading speed · ${wpmLabel(s.wpm)}</span><b>${s.wpm} wpm</b></div>
<input type="range" min="100" max="900" step="25" value="${s.wpm}" data-set="wpm"></div>
<div class="slider"><div class="row"><span>Font size</span><b>${s.fontSize}px</b></div>
<input type="range" min="24" max="96" step="2" value="${s.fontSize}" data-set="fontSize"></div>
</div>
</section>`;
}
function renderProse(text,focus){
const paras = text.split(/\n\s*\n/).map(p=>p.replace(/\s+/g," ").trim()).filter(Boolean);
if (!focus){
return `<div class="prose">${paras.map(p=>`<p>${escHtml(p)}</p>`).join("")}</div>`;
}
const sentences = [];
paras.forEach((p,pi)=>{
const parts = p.match(/[^.!?]+[.!?]+["']?|[^.!?]+$/g) || [p];
parts.forEach(s=>sentences.push({pi, s:s.trim()}));
});
// map word index -> sentence index
let wsum=0, activeS=0;
for (let i=0;i<sentences.length;i++){
const w = countWords(sentences[i].s);
if (state.index < wsum + w){ activeS = i; break; }
wsum += w; activeS = i;
}
// render paragraphs with focus highlight on active sentence
const html = paras.map((p,pi)=>{
const parts = p.match(/[^.!?]+[.!?]+["']?|[^.!?]+$/g) || [p];
let si = 0;
// find starting sentence index for this paragraph
let count = 0;
for (let i=0;i<sentences.length;i++){ if (sentences[i].pi===pi){ si = i; break; } count++; }
const spans = parts.map((s,k)=>{
const gi = si + k;
return `<span class="${gi===activeS?'focus-line':''}">${escHtml(s.trim())} </span>`;
}).join("");
return `<p>${spans}</p>`;
}).join("");
return `<div class="prose">${html}</div>`;
}
function renderLens(){
const s = state.settings;
const cw = s.contextWords|0;
const i = state.index;
const prev = state.words.slice(Math.max(0,i-cw), i).join(" ");
const cur = state.words[i] || "";
const next = state.words.slice(i+1, i+1+cw).join(" ");
const isSprint = s.mode==="sprint";
return `<div class="lens">
<div class="rule"></div><div class="rule b"></div>
${cw>0&&!isSprint?`<div class="ctx">${escHtml(prev)||" "}</div>`:""}
${renderFocalWord(cur)}
${cw>0&&!isSprint?`<div class="ctx">${escHtml(next)||" "}</div>`:""}
</div>`;
}
function renderFocalWord(word){
const w = word || "";
const fi = Math.max(0, Math.floor(w.length * 0.30));
const before = w.slice(0, fi);
const focal = w[fi] || "";
const after = w.slice(fi+1);
return `<div class="word"><span>${escHtml(before)}</span><span class="focal">${escHtml(focal)}</span><span>${escHtml(after)}</span></div>`;
}
function paintReaderTick(){
if (state.screen !== "reader") return;
const s = state.settings;
const cur = state.words[state.index] || "";
const wc = state.words.length;
const pct = wc ? Math.round((state.index/Math.max(1,wc-1))*100) : 0;
const lens = document.querySelector(".lens");
if (lens && (s.mode==="context" || s.mode==="sprint")){
const cw = s.contextWords|0;
const i = state.index;
const prev = state.words.slice(Math.max(0,i-cw), i).join(" ");
const next = state.words.slice(i+1, i+1+cw).join(" ");
const isSprint = s.mode==="sprint";
lens.innerHTML = `<div class="rule"></div><div class="rule b"></div>
${cw>0&&!isSprint?`<div class="ctx">${escHtml(prev)||" "}</div>`:""}
${renderFocalWord(cur)}
${cw>0&&!isSprint?`<div class="ctx">${escHtml(next)||" "}</div>`:""}`;
} else if (s.mode==="focus"){
// re-render prose focus highlight only when active sentence changes
const proseEl = document.querySelector(".prose");
if (proseEl && state.currentPassage){
const fresh = renderProse(state.currentPassage.text, true);
// cheap diff: just replace innerHTML
proseEl.outerHTML = fresh;
}
}
const bar = $("progbar"); if (bar) bar.style.width = pct + "%";
const ptxt = $("progtext"); if (ptxt) ptxt.textContent = `Word ${Math.min(state.index+1,wc)} / ${wc}`;
const pb = $("playBtn"); if (pb) pb.textContent = state.playing?"❚❚ Pause":"▶ Play";
}
function renderComplete(){
const last = state.lastSession || {};
return `<section class="reader complete">
<p class="eyebrow">Session complete</p>
<h2>Nice reading.</h2>
<p class="sub">${escHtml(last.title||"")} · ${escHtml(last.author||"")}</p>
<div class="stat-row">
<div class="stat"><div class="n">${last.wordCount||0}</div><div class="l">Words</div></div>
<div class="stat"><div class="n">${last.wpm||state.settings.wpm}</div><div class="l">WPM</div></div>
<div class="stat"><div class="n">${fmtTime((last.durationSeconds||0)*1000)}</div><div class="l">Time</div></div>
</div>
<div class="rate-lab">Comfort · how did this feel?</div>
<div class="rating" id="comfortRow">
${[1,2,3,4,5,6,7,8,9,10].map(n=>`<button data-act="comfort" data-n="${n}" class="${state.pendingComfort===n?'on':''}">${n}</button>`).join("")}
</div>
<div class="rate-lab">Comprehension · how well did you follow it?</div>
<div class="compcheck">
${["Low","Okay","Good","Great"].map((l,i)=>`<button data-act="comprehension" data-n="${i+1}" class="${state.pendingComprehension===i+1?'on':''}">${escHtml(l)}</button>`).join("")}
</div>
<div class="ctrls" style="margin-top:22px">
<button class="ctrl" data-act="restart">↻ Read again</button>
<button class="ctrl" data-act="tryfaster">Try faster (+50 wpm)</button>
<button class="ctrl" data-act="bookmark">★ Save passage</button>
<button class="ctrl play" data-act="newpassage" data-mood="${escHtml(last.mood||'random')}">New passage →</button>
</div>
</section>`;
}
async function completeSession(){
pause();
const cp = state.currentPassage || {};
const wc = state.words.length;
const durSec = Math.max(1, Math.round(state.elapsedMs/1000));
const session = {
date: new Date().toISOString(),
title: cp.title || "Untitled",
author: cp.author || "",
mood: cp.mood || "random",
mode: state.settings.mode,
wpm: state.settings.wpm,
wordCount: wc,
durationSeconds: durSec,
comfort: null,
comprehension: null
};
state.lastSession = session;
state.history.unshift(session);
state.history = state.history.slice(0, 20);
await safeSave("reader_history", state.history);
state.pendingComfort = null;
state.pendingComprehension = null;
state.screen = "complete";
renderApp();
}
function renderHistory(){
if (!state.history.length){
return `<div class="section-title"><h2>Your reading</h2><small>Sessions & saves</small></div>
<div class="empty">No sessions yet -- read a passage to start your history.</div>
${state.savedPassages.length?renderSaved():""}`;
}
return `<div class="section-title"><h2>Your reading</h2><small>Last 20 sessions</small></div>
${state.history.map(h=>`<div class="hist-item">
<div class="info">
<h4>${escHtml(h.title)}</h4>
<p>${escHtml(h.author||"")} · ${escHtml(h.mood||"")} · ${escHtml(h.mode)} · ${new Date(h.date).toLocaleDateString()}</p>
</div>
<div class="nums">${h.wordCount} words · ${h.wpm} wpm · ${fmtTime((h.durationSeconds||0)*1000)}${h.comfort?` · comfort ${h.comfort}/10`:""}</div>
</div>`).join("")}
${renderSaved()}`;
}
function renderSaved(){
if (!state.savedPassages.length) return "";
return `<div class="section-title"><h2>Saved passages</h2><small>${state.savedPassages.length}</small></div>
${state.savedPassages.map((p,i)=>`<div class="hist-item">
<div class="info">
<h4>${escHtml(p.title)}</h4>
<p>${escHtml(p.author||"")} · ${escHtml(snippetPreview(p.text,120))}</p>
</div>
<div class="nums">
<button class="icobtn" data-act="loadsaved" data-i="${i}">Read</button>
<button class="icobtn" data-act="delsaved" data-i="${i}">*</button>
</div>
</div>`).join("")}`;
}
function renderDrawer(){
const s = state.settings;
return `<button class="close-drawer" data-act="closedrawer" aria-label="Close">Close</button>
<h3>Settings</h3>
<div class="grp">
<div class="field"><div class="row"><span>WPM · ${wpmLabel(s.wpm)}</span><b>${s.wpm}</b></div>
<input type="range" min="100" max="900" step="25" value="${s.wpm}" data-set="wpm"></div>
<div class="field"><div class="row"><span>Font size</span><b>${s.fontSize}px</b></div>
<input type="range" min="24" max="96" step="2" value="${s.fontSize}" data-set="fontSize"></div>
<div class="field"><div class="row"><span>Line height</span><b>${Number(s.lineHeight).toFixed(2)}</b></div>
<input type="range" min="1.1" max="2.2" step="0.05" value="${s.lineHeight}" data-set="lineHeight"></div>
<div class="field"><div class="row"><span>Letter spacing</span><b>${Number(s.letterSpacing).toFixed(2)}em</b></div>
<input type="range" min="0" max="0.3" step="0.01" value="${s.letterSpacing}" data-set="letterSpacing"></div>
<div class="field"><div class="row"><span>Context words</span><b>${s.contextWords}</b></div>
<input type="range" min="0" max="6" step="1" value="${s.contextWords}" data-set="contextWords"></div>
<div class="field"><div class="row"><span>Punctuation pause</span><b>${Number(s.punctuationPause).toFixed(2)}*</b></div>
<input type="range" min="1" max="3" step="0.1" value="${s.punctuationPause}" data-set="punctuationPause"></div>
<div class="field full"><div class="row"><span>Theme</span><b>${themes[s.theme]?themes[s.theme].label:"Custom"}</b></div>
<div class="themes">
${Object.keys(themes).map(k=>`<button class="theme ${s.theme===k?'on':''}" data-act="settheme" data-k="${k}"><span class="sw" style="background:${themes[k].bgColor};border:1px solid ${themes[k].textColor}"></span>${escHtml(themes[k].label)}</button>`).join("")}
</div>
<div class="colorpick">
<label>BG<input type="color" value="${s.bgColor}" data-set="bgColor"></label>
<label>Text<input type="color" value="${s.textColor}" data-set="textColor"></label>
<label>Accent<input type="color" value="${s.accentColor}" data-set="accentColor"></label>
</div>
</div>
<div class="field full"><div class="row"><span>Highlight style</span><b>${s.highlightStyle}</b></div>
<div class="seg">
${["none","bold","color","underline","box"].map(k=>`<button class="${s.highlightStyle===k?'on':''}" data-act="sethl" data-k="${k}">${k}</button>`).join("")}
</div>
</div>
<div class="field full"><div class="row"><span>Reduced motion</span><b>${s.reducedMotion?"on":"off"}</b></div>
<div class="seg">
<button class="${!s.reducedMotion?'on':''}" data-act="setmotion" data-k="0">Off</button>
<button class="${s.reducedMotion?'on':''}" data-act="setmotion" data-k="1">On</button>
</div>
</div>
</div>`;
}
function openDrawer(){ state.drawerOpen=true; $("drawer").innerHTML = renderDrawer(); bindDrawer(); $("drawer").classList.add("open"); $("scrim").classList.add("show"); }
function closeDrawer(){ state.drawerOpen=false; $("drawer").classList.remove("open"); $("scrim").classList.remove("show"); }
function bindEvents(){
document.querySelectorAll("[data-act]").forEach(el=>{
el.onclick = async (e)=>{
const a = el.dataset.act;
if (a === "home"){ pause(); state.screen="home"; renderApp(); }
else if (a === "goto-reader"){ if (state.currentPassage){ if(!state.words.length) prepareWords(state.currentPassage.text); state.screen="reader"; renderApp(); } }
else if (a === "history"){ state.screen="history"; renderApp(); }
else if (a === "opensettings"){ openDrawer(); }
else if (a === "newpassage"){ await loadFromMood(el.dataset.mood || "random"); }
else if (a === "setmode"){ state.settings.mode = el.dataset.m; debouncedSaveSettings(); pause(); renderApp(); }
else if (a === "toggle"){ state.playing?pause():play(); }
else if (a === "back5"){ back5(); }
else if (a === "fwd5"){ fwd5(); }
else if (a === "backsent"){ backSentence(); }
else if (a === "restart"){ restart(); play(); }
else if (a === "tryfaster"){ state.settings.wpm = Math.min(900, state.settings.wpm + 50); debouncedSaveSettings(); state.screen="reader"; restart(); }
else if (a === "bookmark"){ await saveBookmark(); }
else if (a === "comfort"){ state.pendingComfort = +el.dataset.n; applyRatingToLast("comfort", +el.dataset.n); renderApp(); }
else if (a === "comprehension"){ state.pendingComprehension = +el.dataset.n; applyRatingToLast("comprehension", +el.dataset.n); renderApp(); }
else if (a === "loadsaved"){ const sp = state.savedPassages[+el.dataset.i]; if (sp){ state.currentPassage = Object.assign({},sp); prepareWords(sp.text); state.elapsedMs=0; state.startedAt=0; state.index=0; state.screen="reader"; await safeSave("current_passage", sp); renderApp(); } }
else if (a === "delsaved"){ state.savedPassages.splice(+el.dataset.i,1); await safeSave("saved_passages", state.savedPassages); renderApp(); }
};
});
// Range sliders in main reader
document.querySelectorAll("[data-set]").forEach(el=>{
el.oninput = (e)=>{
const key = el.dataset.set;
let v = el.value;
if (el.type === "range") v = Number(v);
state.settings[key] = v;
applyTheme();
debouncedSaveSettings();
// light update of slider label without full re-render
const lab = el.closest(".slider, .field");
if (lab){
const b = lab.querySelector(".row b");
if (b) b.textContent = formatSettingLabel(key, v);
}
// re-render if it materially affects the displayed content
if (key === "fontSize" || key === "lineHeight" || key === "letterSpacing") {}
if (key === "contextWords") paintReaderTick();
};
});
}
function formatSettingLabel(key,v){
if (key==="wpm") return v+" wpm";
if (key==="fontSize") return v+"px";
if (key==="lineHeight") return Number(v).toFixed(2);
if (key==="letterSpacing") return Number(v).toFixed(2)+"em";
if (key==="contextWords") return String(v);
if (key==="punctuationPause") return Number(v).toFixed(2)+"*";
return String(v);
}
function bindDrawer(){
// delegate to bindEvents but only for drawer descendants
$("drawer").querySelectorAll("[data-act]").forEach(el=>{
el.onclick = async (e)=>{
const a = el.dataset.act;
if (a === "closedrawer"){ closeDrawer(); }
else if (a === "settheme"){
const k = el.dataset.k; const t = themes[k]; if (!t) return;
state.settings.theme = k;
state.settings.bgColor = t.bgColor;
state.settings.textColor = t.textColor;
state.settings.accentColor = t.accentColor;
applyTheme(); debouncedSaveSettings();
$("drawer").innerHTML = renderDrawer(); bindDrawer();
}
else if (a === "sethl"){ state.settings.highlightStyle = el.dataset.k; applyTheme(); debouncedSaveSettings(); $("drawer").innerHTML = renderDrawer(); bindDrawer(); }
else if (a === "setmotion"){ state.settings.reducedMotion = el.dataset.k === "1"; applyTheme(); debouncedSaveSettings(); $("drawer").innerHTML = renderDrawer(); bindDrawer(); }
};
});
$("drawer").querySelectorAll("[data-set]").forEach(el=>{
el.oninput = ()=>{
const key = el.dataset.set;
let v = el.value;
if (el.type === "range") v = Number(v);
if (el.type === "color"){ state.settings.theme = "custom"; }
state.settings[key] = v;
applyTheme();
debouncedSaveSettings();
const lab = el.closest(".field");
if (lab){ const b = lab.querySelector(".row b"); if (b) b.textContent = formatSettingLabel(key,v); }
if (key === "contextWords") paintReaderTick();
};
});
$("scrim").onclick = closeDrawer;
}
async function saveBookmark(){
if (!state.currentPassage) return;
const exists = state.savedPassages.find(p=>p.title===state.currentPassage.title && p.author===state.currentPassage.author);
if (exists){ toast("Already in your saved passages"); return; }
const sp = Object.assign({}, state.currentPassage);
if (sp.text && sp.text.length > 5000) sp.text = sp.text.slice(0,5000);
state.savedPassages.unshift(sp);
state.savedPassages = state.savedPassages.slice(0, 30);
await safeSave("saved_passages", state.savedPassages);
toast("Saved passage ★");
}
async function applyRatingToLast(key, value){
if (!state.history.length) return;
state.history[0][key] = value;
await safeSave("reader_history", state.history);
}
let toastTimer;
function toast(msg){
const t = $("toast"); t.textContent = msg; t.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(()=>t.classList.remove("show"), 2200);
}
function bindKeys(){
document.addEventListener("keydown", (e)=>{
if (state.drawerOpen && e.key === "Escape"){ closeDrawer(); return; }
if (state.screen !== "reader") return;
if (e.target && /input|textarea|select/i.test(e.target.tagName)) return;
if (e.key === " "){ e.preventDefault(); state.playing?pause():play(); }
else if (e.key === "ArrowLeft"){ back5(); }
else if (e.key === "ArrowRight"){ fwd5(); }
else if (e.key === "r" || e.key === "R"){ restart(); }
else if (e.key === "n" || e.key === "N"){ loadFromMood((state.currentPassage&&state.currentPassage.mood)||"random"); }
});
}
async function initApp(){
state.settings = Object.assign({}, defaultSettings, await safeLoad("reader_settings", {}));
state.history = await safeLoad("reader_history", []);
state.savedPassages = await safeLoad("saved_passages", []);
state.currentPassage = await safeLoad("current_passage", null);
applyTheme();
if (state.currentPassage){ prepareWords(state.currentPassage.text); state.screen = "home"; }
bindKeys();
renderApp();
if (!state.currentPassage){
// soft-load a passage in background so the home hero has something the next time
loadFromMood("random").catch(()=>{});
}
}
window.vibes.onReady(async ()=>{ await initApp(); });</script>
</body></html>
```
[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 HTML 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/GigaChad/good-parts-reader */