
Immersive Reading
- 45 repo stars
- Updated July 1, 2026
- ryannli/immersive-reading
Immersive Reading is a Claude Code agent skill that converts any long-form source - essay, post, transcript, note, paper, or URL - into a polished, self-contained interactive reader instead of a summary.
About
Immersive Reading is a Claude Code agent skill from Ran Li that converts any long-form source - an essay, blog post, transcript, note, paper, or URL - into a polished, self-contained interactive reader instead of a summary. The agent reads the source and builds structure (chapters, section beats, anchor quotes, preserved media) while bundled scripts scaffold, schema-validate and smoke-test a static reader template, so you get a consistently high-quality result without redesigning any UI. The output is a co-reading study space: full-text search, highlights, copyable notes, source attribution, optional bilingual mode, and light/dark themes - designed so you still read, digest and think for yourself, just in a far more engaging and navigable way. It installs as a Claude Code plugin or via npx skills add ryannli/immersive-reading, natively supports Codex, Cursor, Antigravity and other agents, ships static output ready for local use or Vercel, and is MIT licensed.
- Not a summary - a co-reading space that keeps you doing the thinking
- Turns any essay, transcript, paper, note or URL into a chaptered interactive reader
- Ships full-text search, highlights, notes, source attribution, bilingual mode and light/dark themes
- Bundled reader template + scaffold/validate scripts: the agent fills content, not UI
- Static, self-contained output ready to run locally or deploy to Vercel; MIT licensed
Immersive Reading by the numbers
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ryannli/immersive-reading --skill immersive-readingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| repo stars | ★ 45 |
|---|---|
| Last updated | July 1, 2026 |
| Repository | ryannli/immersive-reading ↗ |
What it does
You have a dense essay, paper, transcript, or long post and want to actually digest it - turn it into a beautiful interactive reader with chapters, search, highlights and notes, not a lossy TL;DR.
Who is it for?
Readers, researchers and builders who want to deeply digest a specific long essay, paper, transcript or article - and keep a reusable, navigable study space rather than a throwaway TL;DR.
Skip if: Getting a quick one-paragraph gist, bulk-summarizing a feed, or any task where you do not actually want to read the source - this keeps you in the reading, it does not replace it.
When should I use this skill?
When you want to turn a blog post, essay, transcript, note, paper, or URL into a chaptered static reader with search, highlights, notes, bilingual support and themes.
What you get
You get a beautiful chaptered reading space with search, anchor quotes, highlights, notes and source attribution, so you actively read and retain long-form ideas in a much more engaging way.
- a self-contained static interactive reader site (chapters, search, highlights, notes, attribution)
- validated article-data.js matching the reader template schema
By the numbers
- MIT licensed
- 26 GitHub stars (launched 2026-06-30)
- installs as a Claude Code plugin or via npx skills add
Files
Immersive Reading
Overview
Create a self-contained static reading site from new source material by reusing the bundled reader template. Do not redesign the UI from scratch unless the user explicitly asks; the template already encodes the polished interaction details.
The skill separates judgment from deterministic work:
- Use the model for source structure, chapter/section titles, quotes, summaries, translation, and editorial judgment.
- Use bundled scripts for scaffolding, schema validation, and smoke tests.
Workflow
1. Intake Ask only for missing essentials:
- source content, source URL, or source file
- whether to include bilingual mode and target language, if not already clear
Do not ask for an output folder by default. Choose a sensible local folder name from the source title or article slug, then tell the user where the site was created. Only ask for a destination when the user has special placement or deployment requirements.
2. Source Handling Treat the project as a local reading/study space by default. Do not block the workflow with ownership questions. Preserve source title, author, and original link when they are known. If the original source contains meaningful media (images, diagrams, screenshots, embeds, audio, or video), preserve it as part of the learning site. Prefer downloading stable media into the generated site's assets/source/ folder; otherwise keep the original media URL. Add the media to the relevant section or paragraph data with alt text, captions, and source credit when available, then verify it renders in preview.
3. Content Conversion Read references/content-model.md and produce one article-data.js file matching the template schema. If the source lacks structure, create 5-14 chapters and 1-4 sections per chapter. Each section needs:
take: clear section titlequote: short anchor quote or excerptparagraphs: source text, summary text, or translated/source-aligned text- optional
footnotes
4. Scaffold Run:
node skills/immersive-reading/scripts/scaffold-reader.mjs --article-data /path/to/article-data.js --out /path/to/output5. Validate Run the generated-site checks:
node skills/immersive-reading/scripts/validate-article-data.mjs /path/to/output/src/articles/<article-id>/data.js
node skills/immersive-reading/scripts/smoke-test-reader.mjs /path/to/outputIf you changed this skill's template, references, or scripts, also run:
node skills/immersive-reading/scripts/check-skill.mjs6. Preview Serve the generated folder with the bundled local server, inspect desktop and mobile, and fix data/layout problems before delivery. Use this server for preview because it supports byte-range video seeking required by the scroll-scrub opening:
node skills/immersive-reading/scripts/serve-reader.mjs /path/to/output --port 87917. Deploy Read references/deployment.md only when the user asks to publish.
Template Boundary
Use assets/reader-template/ as the source of truth for the generated site. The template intentionally excludes production analytics, dashboards, Upstash/Vercel secrets, and project-specific branding.
Preserve these features unless the user asks otherwise:
- chapter/section reading flow
- search
- highlights
- notes and copyable notes
- optional bilingual display
- light/dark mode
- mobile limited-functionality notice
- source attribution and original link
- 3D particle background and scroll-driven transitions
Read references/design-contract.md before changing template layout, animation, or visual hierarchy.
interface:
display_name: "Immersive Reading"
short_description: "Create bespoke learning spaces"
default_prompt: "Use $immersive-reading to turn this long-form source into a bespoke learning space."
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Immersive Reading</title>
<meta name="description" content="A bespoke learning space for close study, notes, and source attribution.">
<meta property="og:type" content="article">
<meta property="og:title" content="Immersive Reading">
<meta property="og:description" content="A bespoke learning space for close study, notes, and source attribution.">
<meta property="og:site_name" content="Immersive Reading">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Immersive Reading">
<meta name="twitter:description" content="A bespoke learning space for close study, notes, and source attribution.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..500&family=Newsreader:ital,opsz,wght@0,6..72,300..600;1,6..72,300..500&family=Schibsted+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="src/styles.css?v=43">
</head>
<body class="light">
<canvas id="gl"></canvas>
<div id="scrim"></div>
<div id="veil"></div>
<div class="grain"><svg xmlns="http://www.w3.org/2000/svg"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.82" numOctaves="3" stitchTiles="stitch"/></filter><rect width="100%" height="100%" filter="url(#n)"/></svg></div>
<div id="topfade"></div>
<div class="tprog" id="tprog"></div>
<header class="topbar">
<button class="brand" onclick="toTop()">Reader</button>
<div class="right">
<span class="now" id="now">The Beginning</span>
<button class="theme-btn" id="themeBtn" onclick="toggleTheme()" title="Toggle light / dark" aria-label="Toggle light / dark">☾</button>
<button class="lang-btn" onclick="toggleLangMenu(event)"><span class="gl-i">Aa</span><span class="lab-txt" id="langLab">EN</span><span class="cv">▾</span></button>
<button class="tbtn" onclick="toggleSearch()" title="Search" aria-label="Search"><svg class="tic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M20 20l-4.2-4.2"/></svg><span class="lab">Search</span></button>
<button class="tbtn" onclick="toggleHighlights()" title="Highlights" aria-label="Highlights"><svg class="tic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16.5 4.5l3 3L8 19l-4 1 1-4 11.5-11.5z"/><path d="M14.5 6.5l3 3"/></svg><span class="lab">Highlights</span></button>
<button class="tbtn" onclick="toggleContents()" title="Contents" aria-label="Contents"><svg class="tic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg><span class="lab">Contents</span></button>
</div>
</header>
<div id="langMenu"></div>
<div id="spine-scrim"></div>
<nav class="spine" id="spine"></nav>
<main id="main">
<section class="door-intro" id="doorIntro" aria-label="Reading entrance">
<div class="door-intro-scene">
<video class="door-intro-video" id="doorIntroVideo" src="assets/door-entrance-8s-scrub.mp4" poster="assets/door-entrance-8s-poster.jpg" muted playsinline preload="auto" aria-hidden="true"></video>
<div class="door-intro-grade"></div>
<div class="door-intro-wash"></div>
<div class="door-intro-label">
<div>Source material <span></span></div>
<div class="door-intro-purpose"></div>
</div>
<section class="hero" id="hero">
<h1 id="heroTitle" aria-label=""></h1>
<p class="hero-author"></p>
<p class="hero-note"></p>
<div class="hero-actions">
<button class="hero-start" id="heroPrimary" type="button">Start reading</button>
</div>
<div class="scroll-cue">Scroll<span>↓</span></div>
</section>
<div class="door-intro-cue"><span class="door-intro-cue-text">Enter</span><span class="door-intro-cue-arrow">↓</span></div>
</div>
</section>
<div id="chapters"></div>
<footer class="foot">
<div class="fq"></div>
<div class="credit"></div>
<button class="totop" onclick="toTop()">Return to the top ↑</button>
</footer>
</main>
<div id="contents" class="ov-screen" data-lenis-prevent>
<button class="ov-close" onclick="toggleContents()">Close ✕</button>
<div class="co-inner">
<div class="co-top"><h3>Contents</h3>
<a class="util-src" href="#" target="_blank" rel="noopener">Original ↗</a></div>
<div id="coList"></div>
</div>
</div>
<div id="search" class="ov-screen" data-lenis-prevent>
<button class="ov-close" onclick="toggleSearch()">Close ✕</button>
<div class="se-inner">
<div class="se-field"><span class="mg">⌕</span><input id="seInput" type="text" placeholder="Search the whole essay…" autocomplete="off" spellcheck="false"></div>
<div class="se-hint"><span></span></div>
<div class="se-results" id="seResults"></div>
</div>
</div>
<div id="highlights" class="ov-screen" data-lenis-prevent>
<button class="ov-close" onclick="toggleHighlights()">Close ✕</button>
<div class="hl-inner">
<h3 class="hl-title">Highlights</h3>
<p class="hl-sub">The author's key lines, plus anything you mark yourself. Select any passage in the essay to add your own — remove any line you don't want.</p>
<div id="hlBody"></div>
</div>
</div>
<div id="mobileNotice" class="mobile-notice" role="dialog" aria-modal="true" aria-labelledby="mobileNoticeTitle" aria-describedby="mobileNoticeText" data-lenis-prevent>
<div class="mobile-notice-card">
<div class="mobile-notice-k">Limited functionality</div>
<h3 id="mobileNoticeTitle">Best experienced on a computer</h3>
<p id="mobileNoticeText">Reading works on mobile. For highlighting and notes, open the desktop version for the full experience.</p>
<button id="mobileNoticeClose" type="button">Continue reading</button>
</div>
</div>
<script src="https://unpkg.com/lenis@1.1.14/dist/lenis.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script type="module" src="src/app.js?v=12"></script>
</body>
</html>
import { getArticle } from './articles/index.js?v=1';
import { createFrontier } from './frontier.js?v=26';
const articleId = new URLSearchParams(location.search).get('article') || document.documentElement.dataset.article;
const ARTICLE = getArticle(articleId);
const DATA = ARTICLE.chapters;
const FOOT = ARTICLE.footnotes || {};
const BASE_LANG = ARTICLE.defaultLanguage || 'en';
const LANGUAGE_LIST = ARTICLE.languages || [{code: BASE_LANG, shortLabel: 'EN', label: 'English', primary: true}];
const PRIMARY_LANGUAGE = LANGUAGE_LIST.find(l => l.primary) || LANGUAGE_LIST.find(l => l.code === BASE_LANG) || LANGUAGE_LIST[0];
const TRANSLATION_LANGS = LANGUAGE_LIST.filter(l => l.code !== PRIMARY_LANGUAGE.code);
const LANGS = Object.fromEntries(LANGUAGE_LIST.map(l => [l.code, l]));
const WPM=115;
const textOf=(value,lang=BASE_LANG)=>{
if(value==null)return '';
if(typeof value==='string')return value;
return value[lang] || value[BASE_LANG] || value[PRIMARY_LANGUAGE.code] || Object.values(value)[0] || '';
};
const translationEntries=value=>TRANSLATION_LANGS.map(lang=>[lang,textOf(value,lang.code)]).filter(([,text])=>text);
const fnWords=Object.values(FOOT).reduce((s,t)=>s+textOf(t).split(/\s+/).length,0);
const totalWords=DATA.reduce((s,c)=>s+c.words,0)+fnWords;
const $=s=>document.querySelector(s);
const ce=(t,c)=>{const e=document.createElement(t);if(c)e.className=c;return e;};
const mins=w=>Math.max(1,Math.round(w/WPM));
const pad=n=>String(n).padStart(2,'0');
/* Restraint: one cohesive warm-espresso palette (like Oryzo's 2-colour scheme), with only a whisper of warm drift across chapters — not a rainbow. */
function ovPalette(i){const t=DATA.length>1?i/(DATA.length-1):0;const h=Math.round(24+t*14);return{deep:`hsl(${h} 24% 6.5%)`,mid:`hsl(${h} 28% 9.5%)`,glow:`hsla(${h+6} 46% 22% / .82)`,glow2:`hsla(${h-2} 36% 16% / .75)`,tint:`hsl(${h} 14% 92.8%)`};}
const isPro=n=>n===0, MOVES=DATA.length-1;
const chBig=n=>isPro(n)?'·':pad(n);
const chTag=n=>isPro(n)?'Pro':pad(n);
const chKick=n=>isPro(n)?'Prologue · the opening':`Chapter ${pad(n)} · of ${MOVES}`;
const secNo=(n,i)=>isPro(n)?'Opening':`Section ${n}.${i+1}`;
const teaserNo=(n,i)=>isPro(n)?'':`${n}.${i+1}`;
const nowLabel=n=>n<0?'The Beginning':(isPro(n)?'Prologue':`${chTag(n)} · ${textOf(DATA[n].title)}`);
const esc=s=>String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
const escAttr=s=>esc(s).replace(/"/g,'"');
const fnref=t=>t.replace(/\[(\d+)\]/g,'<sup class="fnref" data-n="$1" tabindex="0" role="button">$1</sup>');
const stripFn=t=>t.replace(/\[(\d+)\]/g,'');
const renderTranslations=value=>translationEntries(value).map(([lang,text])=>`<span class="tr lang-${lang.code}" data-lang="${lang.code}" lang="${lang.code}">${esc(text)}</span>`).join('');
const renderLocalized=value=>esc(textOf(value));
function mediaType(item,src){
if(item.type)return item.type;
if(/\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(src))return 'image';
if(/\.(mp4|webm|mov)(\?|#|$)/i.test(src))return 'video';
if(/\.(mp3|m4a|wav|ogg)(\?|#|$)/i.test(src))return 'audio';
return 'link';
}
function renderMedia(media){
if(!Array.isArray(media)||!media.length)return '';
return media.map((item)=>{
const src=item.src||item.url||item.href||'';
if(!src)return '';
const type=mediaType(item,src);
const alt=textOf(item.alt);
const caption=textOf(item.caption);
const credit=typeof item.credit==='object'?textOf(item.credit):(item.credit||'');
const href=item.href||item.originalUrl||item.sourceUrl||src;
const captionHtml=(caption||credit)?`<figcaption>${caption?esc(caption):''}${credit?`<span>${esc(credit)}</span>`:''}</figcaption>`:'';
if(type==='image'){
const image=`<img src="${escAttr(src)}" alt="${escAttr(alt)}" loading="lazy" decoding="async">`;
return `<figure class="source-media source-media-image reveal">${href?`<a href="${escAttr(href)}" target="_blank" rel="noopener">${image}</a>`:image}${captionHtml}</figure>`;
}
if(type==='video'){
const poster=item.poster?` poster="${escAttr(item.poster)}"`:'';
return `<figure class="source-media source-media-video reveal"><video src="${escAttr(src)}"${poster} controls playsinline preload="metadata"></video>${captionHtml}</figure>`;
}
if(type==='audio'){
return `<figure class="source-media source-media-audio reveal"><audio src="${escAttr(src)}" controls preload="metadata"></audio>${captionHtml}</figure>`;
}
const label=caption||alt||src;
return `<figure class="source-media source-media-link reveal"><a href="${escAttr(href)}" target="_blank" rel="noopener">${esc(label)} ↗</a>${credit?`<figcaption><span>${esc(credit)}</span></figcaption>`:''}</figure>`;
}).join('');
}
const DEFAULT_DOOR_VIDEO='assets/door-entrance-8s-scrub.mp4';
const DEFAULT_DOOR_POSTER='assets/door-entrance-8s-poster.jpg';
function heroTitleHTML(title){
const words=title.trim().split(/\s+/);
const split=Math.max(1,Math.ceil(words.length/2));
const pre=words.slice(0,split).map((word,i)=>`<span class="title-word title-pre-word title-word-${i}">${esc(word)}</span>`).join(' ');
const main=words.slice(split).map((word,i)=>`<span class="title-word title-main-word title-word-${i}">${esc(word)}</span>`).join(' ');
return `<span class="title-line title-pre">${pre}</span> <span class="title-line title-main"><em>${main}</em></span>`;
}
const events={track(){},markSection(){},markChapter(){}};
function applyArticleChrome(){
document.documentElement.dataset.article=ARTICLE.id;
document.documentElement.lang=PRIMARY_LANGUAGE.code;
document.title=`${textOf(ARTICLE.title)} · Immersive Reading`;
const meta=document.querySelector('meta[name="description"]');if(meta)meta.content=textOf(ARTICLE.description);
const brand=$('.brand');if(brand)brand.textContent=textOf(ARTICLE.title);
const door=$('#doorIntro');if(door)door.setAttribute('aria-label',ARTICLE.door?.ariaLabel||'Reading entrance');
const video=$('#doorIntroVideo');if(video){video.src=ARTICLE.door?.video||video.getAttribute('src')||DEFAULT_DOOR_VIDEO;video.poster=ARTICLE.door?.poster||video.getAttribute('poster')||DEFAULT_DOOR_POSTER;}
const doorLabel=$('.door-intro-label');if(doorLabel)doorLabel.innerHTML=`<div>${esc(ARTICLE.source.label)} <span>${esc(ARTICLE.source.authorLine)}</span></div><div class="door-intro-purpose">${esc(ARTICLE.door?.purpose||'')}</div>`;
const heroTitle=$('#heroTitle');if(heroTitle)heroTitle.setAttribute('aria-label',textOf(ARTICLE.title));
const heroAuthor=$('.hero-author');if(heroAuthor)heroAuthor.innerHTML=`<span>By ${esc(ARTICLE.author)}</span><a class="hero-original-link" href="${esc(ARTICLE.source.url)}" target="_blank" rel="noopener" aria-label="${esc(ARTICLE.hero?.originalLinkLabel||'See original')}">See Original <span aria-hidden="true">↗</span></a>`;
const heroNote=$('.hero-note');if(heroNote)heroNote.textContent=ARTICLE.hero?.note||'';
const heroPrimary=$('#heroPrimary');if(heroPrimary)heroPrimary.textContent=ARTICLE.hero?.primaryAction||'Start reading';
const scrollCue=$('.scroll-cue');if(scrollCue)scrollCue.innerHTML=`${esc(ARTICLE.hero?.scrollCue||'Scroll')}<span>↓</span>`;
const doorCue=$('.door-intro-cue-text');if(doorCue)doorCue.textContent=ARTICLE.door?.cue||'Enter';
const footerQuote=$('.foot .fq');if(footerQuote){const quote=textOf(ARTICLE.footer?.quote);const emphasis=ARTICLE.footer?.quoteEmphasis||'';footerQuote.innerHTML='“'+esc(emphasis?quote.replace(emphasis,''):quote)+(emphasis?` <b>${esc(emphasis)}</b>`:'')+'”';}
const credit=$('.foot .credit');if(credit){const srcLabel=ARTICLE.source?.label||'Source material';const srcUrl=ARTICLE.source?.url||'';const srcUrlLabel=ARTICLE.source?.urlLabel||srcUrl||'Original source';const srcLine=srcUrl?`${esc(srcLabel)} — <a href="${esc(srcUrl)}" target="_blank" rel="noopener">${esc(srcUrlLabel)}</a>.`:esc(srcLabel);credit.innerHTML=`${srcLine}<br>${esc(ARTICLE.footer?.credit||'')}`;}
const sourceLink=$('.util-src');if(sourceLink)sourceLink.href=ARTICLE.source.url;
const heroOriginal=$('.hero-original-link');if(heroOriginal)heroOriginal.addEventListener('click',()=>events.track('original_link_click',{surface:'hero'}));
if(sourceLink)sourceLink.addEventListener('click',()=>events.track('original_link_click',{surface:'contents'}));
const hint=$('.se-hint span');if(hint){const languageNames=TRANSLATION_LANGS.map(l=>l.label).join(', ');hint.textContent=`Searches titles, text, all ${Object.keys(FOOT).length} footnotes${languageNames?` — and ${languageNames} when enabled`:''}.`;}
renderLanguageMenu();
}
applyArticleChrome();
let AIREMOVED=new Set();
try{JSON.parse(localStorage.getItem('ir_ai_removed')||'[]').forEach(x=>AIREMOVED.add(x));}catch(e){}
function renderPara(raw,quote,aid){
const i=(quote&&!AIREMOVED.has(aid))?raw.indexOf(quote):-1;
if(i>=0)return fnref(raw.slice(0,i))+'<mark class="gl" data-ai="'+aid+'">'+fnref(quote)+'</mark>'+fnref(raw.slice(i+quote.length));
return fnref(raw);
}
let seen=new Set();
try{JSON.parse(localStorage.getItem('ir_seen')||'[]').forEach(x=>seen.add(x));}catch(e){}
/* HERO */
$('#heroTitle').innerHTML=heroTitleHTML(textOf(ARTICLE.title));
const doorIntro=$('#doorIntro'), doorIntroVideo=$('#doorIntroVideo');
const DOOR_VIDEO_END=.76, DOOR_FALLBACK_DURATION=8;
if(doorIntro)document.body.style.setProperty('--frontier-opacity','0');
if(doorIntro)document.body.style.setProperty('--intro-spine','0');
if(doorIntro)document.body.classList.add('door-video-active');
const totMinEl=$('#totMin');if(totMinEl)totMinEl.textContent=mins(totalWords);
const movNEl=$('#movN');if(movNEl)movNEl.textContent=DATA.length-1;
document.body.dataset.paper='porcelain';
function applyTheme(light,options={}){
document.body.classList.toggle('light',light);
const btn=$('#themeBtn');if(btn)btn.textContent=light?'☀':'☾';
try{localStorage.setItem('ir_theme',light?'light':'dark');}catch(e){}
window.dispatchEvent(new CustomEvent('il-theme',{detail:{light}}));
if(options.track)events.track('theme_change',{theme:light?'light':'dark'});
}
function toggleTheme(){applyTheme(!document.body.classList.contains('light'),{track:true});}
(function(){let t='light';try{t=localStorage.getItem('ir_theme')||'light';}catch(e){}applyTheme(t==='light');})();
(function typeHero(){
const el=document.querySelector('.typed-hero');if(!el)return;
const line=ARTICLE.hero?.typedLine || 'This source has a structure you can learn.';
let i=0;
function step(){el.textContent=line.slice(0,i++);if(i<=line.length){const ch=line[i-2]||'';setTimeout(step,/[—.,]/.test(ch)?170:42+Math.random()*34);}}
setTimeout(step,1450);
})();
/* BUILD CHAPTERS */
const wrap=$('#chapters');
DATA.forEach((c,n)=>{
const sec=ce('section','chapter');sec.id='ch-'+n;sec.dataset.ch=n;
const movement=ce('div','movement');movement.id='mv-'+n;movement.dataset.ch=n;
const lead=c.ideas[0];
const essenceWords=esc(textOf(c.essence)).split(/\s+/).map(w=>`<span class="w">${w}</span>`).join(' ');
movement.innerHTML=`<div class="ov-pin">
<div class="ov-label">${chKick(n)}</div>
<div class="ov-stage">
<p class="ov-essence">${essenceWords}</p>
<h2 class="ov-title">${renderLocalized(c.title)}</h2>
</div>
<div class="ov-meta"><span><b>${c.ideas.length}</b> ${c.ideas.length>1?'sections':'section'}</span><span><b>${mins(c.words)}</b> min</span><span>scroll to begin ↓</span></div>
</div>`;
sec.appendChild(movement);
const bw=ce('div','body-wrap read');
c.ideas.forEach((idea,i)=>{
const art=ce('article',['sec',n===0&&i===0?'first':''].filter(Boolean).join(' '));art.id=`idea-${n}-${i}`;
const paras=idea.paragraphs.map((paragraph,pi)=>{
const base=textOf(paragraph.text);
return `<p class="pp reveal" data-pid="${n}-${i}-${pi}"><span class="en">${renderPara(base,textOf(idea.quote),`${n}-${i}`)}</span>${renderTranslations(paragraph.text)}</p>${renderMedia(paragraph.media)}`;
}).join('');
const sectionMedia=renderMedia(idea.media);
const notes=(idea.footnotes&&idea.footnotes.length)
? `<div class="fn-list reveal"><div class="fn-h">Notes</div>${idea.footnotes.map(f=>`<div class="fn" data-n="${f.n}"><span class="fn-n">${f.n}</span><span class="fn-t"><span class="en">${esc(textOf(f.text))}</span>${renderTranslations(f.text)}</span></div>`).join('')}</div>`
: '';
art.innerHTML=`<div class="sec-lead">
<div class="sec-heading">
<h3 class="sec-title reveal">${renderLocalized(idea.take)}</h3>
<span class="sec-no reveal">${secNo(n,i)}</span>
</div>
<blockquote class="sec-quote reveal"><span class="qm">“</span>${renderLocalized(idea.quote)}</blockquote>
</div>
<div class="sec-prose">${sectionMedia}${paras}${notes}</div>`;
art.querySelectorAll('.fnref').forEach(rf=>rf.addEventListener('click',()=>flashNote(art,rf.dataset.n)));
bw.appendChild(art);
});
sec.appendChild(bw);
wrap.appendChild(sec);
});
document.querySelectorAll('.ov-tz').forEach(b=>b.onclick=()=>{const g=b.dataset.go.split('-');scrollTo(document.getElementById('idea-'+g[0]+'-'+g[1]),-60);});
const leads=[...document.querySelectorAll('.sec-lead')];
const movementEls=[...document.querySelectorAll('.movement')];
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
function flashNote(art,n){const fn=art.querySelector('.fn[data-n="'+n+'"]');if(fn){fn.scrollIntoView({block:'center',behavior:'smooth'});fn.classList.add('flash');setTimeout(()=>fn.classList.remove('flash'),1600);}}
function revealArticle(el){if(!el)return;el.querySelectorAll('.sec-lead,.sec-heading,.sec-prose').forEach(x=>x.classList.add('in'));}
/* SPINE */
const spine=$('#spine');
const rail=ce('div','rail');rail.innerHTML='<div class="bg"></div><div class="fill" id="spineFill"></div>';
spine.appendChild(rail);
DATA.forEach((c,n)=>{
const node=ce('div','node');node.dataset.ch=n;node.style.top=(n/(DATA.length-1)*100)+'%';node.style.setProperty('--d',n);
node.innerHTML=`<span class="dot"></span><span class="lab"><b>${chTag(n)}</b> ${renderLocalized(c.title)}</span>`;
node.onclick=()=>scrollToCh(n);
rail.appendChild(node);
});
let spinePeekTimer=null;
function setSpinePeek(){
clearTimeout(spinePeekTimer);
document.body.classList.add('peek-spine');
}
function clearSpinePeekSoon(delay=110){
clearTimeout(spinePeekTimer);
spinePeekTimer=setTimeout(()=>document.body.classList.remove('peek-spine'),delay);
}
function inSpineHotZone(e){
if(e.pointerType==='touch')return false;
const r=rail.getBoundingClientRect();
const open=document.body.classList.contains('peek-spine');
const right=open?Math.min(window.innerWidth*.2,240):86;
const yPad=open?24:12;
return e.clientX>=0&&e.clientX<=right&&e.clientY>=r.top-yPad&&e.clientY<=r.bottom+yPad;
}
spine.addEventListener('pointerenter',setSpinePeek);
spine.addEventListener('pointerleave',clearSpinePeekSoon);
window.addEventListener('pointermove',e=>{inSpineHotZone(e)?setSpinePeek():clearSpinePeekSoon();},{passive:true});
window.addEventListener('blur',()=>document.body.classList.remove('peek-spine'));
/* CONTENTS */
const coList=$('#coList');
DATA.forEach((c,n)=>{
const row=ce('div','co-row');row.dataset.ch=n;
row.innerHTML=`<div class="co-num">${chTag(n)}</div>
<div class="co-main"><h4>${renderLocalized(c.title)}</h4><p>${renderLocalized(c.essence)}</p></div>
<div class="co-meta">${c.ideas.length} sec · ${mins(c.words)} min<span class="co-dot"></span></div>`;
row.onclick=()=>{toggleContents();setTimeout(()=>scrollToCh(n),120);};
coList.appendChild(row);
});
/* SMOOTH SCROLL */
let lenis=null;
if(window.Lenis){lenis=new Lenis({lerp:.085,wheelMultiplier:.72,smoothWheel:true});function raf(t){lenis.raf(t);requestAnimationFrame(raf);}requestAnimationFrame(raf);}
function scrollTo(target,offset=0){if(lenis)lenis.scrollTo(target,{offset,duration:1.25});else(typeof target==='number'?window.scrollTo({top:target,behavior:'smooth'}):target.scrollIntoView({behavior:'smooth'}));}
function scrollToCh(n){scrollTo($('#ch-'+n),-4);}
function toTop(){scrollTo(0);}
/* REVEAL */
const typed=new WeakSet();
const OV_WORD_DELAY=118;
function showWords(m){if(m._wdone)return;m._wdone=true;m.querySelectorAll('.ov-essence .w').forEach(w=>w.classList.add('show'));}
function revealOv(m){
if(typed.has(m))return;typed.add(m);m.classList.add('in');
const ws=[...m.querySelectorAll('.ov-essence .w')];
ws.forEach((w,k)=>setTimeout(()=>w.classList.add('show'),240+k*OV_WORD_DELAY));
setTimeout(()=>{m._wdone=true;},240+ws.length*OV_WORD_DELAY+360);
}
const ovIO=new IntersectionObserver((es)=>{es.forEach(e=>{if(e.isIntersecting)revealOv(e.target);});},{rootMargin:'-22% 0px -22% 0px',threshold:0});
movementEls.forEach(el=>ovIO.observe(el));
const rIO=new IntersectionObserver((es)=>{es.forEach(e=>e.target.classList.toggle('in',e.isIntersecting));},{rootMargin:'0px 0px -24% 0px',threshold:.1});
document.querySelectorAll('.read .reveal').forEach(el=>rIO.observe(el));
const secIO=new IntersectionObserver((entries)=>{entries.forEach(entry=>{
if(!entry.isIntersecting)return;
const parts=(entry.target.id||'').replace('idea-','').split('-').map(Number);
if(parts.length===2)events.markSection(parts[0],parts[1],{section_label:secNo(parts[0],parts[1])});
});},{rootMargin:'-22% 0px -42% 0px',threshold:.2});
document.querySelectorAll('.sec').forEach(el=>secIO.observe(el));
/* PROGRESS + ACTIVE */
const overtures=[...document.querySelectorAll('.chapter')];
let curCh=-1;
function markSeen(n){
if(n>=0){
events.markChapter(n,{chapter_number:n+1,title:textOf(DATA[n].title)});
if(!seen.has(n)){seen.add(n);localStorage.setItem('ir_seen',JSON.stringify([...seen]));paintSeen();}
}
}
function paintSeen(){document.querySelectorAll('.spine .node').forEach(nd=>nd.classList.toggle('seen',seen.has(+nd.dataset.ch)));document.querySelectorAll('.co-row').forEach(r=>r.classList.toggle('seen',seen.has(+r.dataset.ch)));}
function onScroll(){
const st=window.scrollY||document.documentElement.scrollTop;
const h=document.documentElement.scrollHeight-window.innerHeight;const p=h>0?st/h:0;
$('#tprog').style.width=(p*100)+'%';$('#spineFill').style.height=(p*100)+'%';
const vh=window.innerHeight;
if(doorIntro){
const r=doorIntro.getBoundingClientRect();
const span=Math.max(1,doorIntro.offsetHeight-vh);
const q=clamp(-r.top/span,0,1);
const scrub=clamp(q/DOOR_VIDEO_END,0,1);
if(doorIntroVideo){
const d=Number.isFinite(doorIntroVideo.duration)&&doorIntroVideo.duration>0?doorIntroVideo.duration:DOOR_FALLBACK_DURATION;
const target=Math.min(d-.04,scrub*d);
try{if(Math.abs(doorIntroVideo.currentTime-target)>.04)doorIntroVideo.currentTime=target;}catch(e){}
}
const videoFade=clamp((q-.56)/.22,0,1);
const washIn=clamp((q-.58)/.18,0,1);
const sphere=clamp((q-.54)/.16,0,1);
const wash=washIn*(1-sphere);
const title=clamp((q-.60)/.15,0,1);
const details=clamp((q-.70)/.13,0,1);
doorIntro.style.setProperty('--door-video-opacity',(1-videoFade).toFixed(3));
doorIntro.style.setProperty('--door-media',(1-videoFade).toFixed(3));
doorIntro.style.setProperty('--door-wash',wash.toFixed(3));
doorIntro.style.setProperty('--door-label',clamp(1-q*1.6,0,1).toFixed(3));
doorIntro.style.setProperty('--door-cue',clamp(1-q*2.2,0,1).toFixed(3));
doorIntro.style.setProperty('--door-hero',title.toFixed(3));
doorIntro.style.setProperty('--door-details',details.toFixed(3));
doorIntro.style.setProperty('--door-hero-scale',(1.16-title*.16).toFixed(3));
doorIntro.style.setProperty('--door-hero-y',(24*(1-title)).toFixed(1)+'px');
doorIntro.style.setProperty('--door-details-y',(12*(1-details)).toFixed(1)+'px');
document.body.style.setProperty('--frontier-opacity',sphere.toFixed(3));
const spine=clamp((q-.82)/.12,0,1);
document.body.style.setProperty('--intro-spine',spine.toFixed(3));
document.body.classList.toggle('door-video-active',spine<.98);
}
// Chapter timeline: the sticky pin stays fixed; scroll only scrubs --cmp/--ex/--em.
for(const m of movementEls){
const tr=m.getBoundingClientRect();
const span=Math.max(1,tr.height-vh);
const q=span>0?clamp(-tr.top/span,0,1):0;
const cmp=clamp((q-0.12)/0.38,0,1), ttl=clamp((q-0.50)/0.18,0,1), ex=clamp((q-0.82)/0.14,0,1), em=clamp((q-0.91)/0.09,0,1), meta=cmp*.8*clamp((0.78-q)/0.14,0,1);
m.style.setProperty('--cmp',cmp.toFixed(3));m.style.setProperty('--ttl',ttl.toFixed(3));m.style.setProperty('--ex',ex.toFixed(3));m.style.setProperty('--em',em.toFixed(3));m.style.setProperty('--meta',meta.toFixed(3));
if(typed.has(m)&&!m._wdone&&ttl>0.05)showWords(m);
}
// Non-pinned section leads keep a quieter scroll reveal.
for(const Ld of leads){const r=Ld.getBoundingClientRect();if(r.bottom<-50||r.top>vh+80)continue;
if(Ld.closest('.pin-first'))continue;
const c=r.top+r.height/2;const p=clamp((vh*0.5-c)/(vh*0.5),0,1);const e=clamp((r.top-vh*0.18)/(vh*0.62),0,1);
Ld.style.setProperty('--p',p.toFixed(3));Ld.style.setProperty('--e',e.toFixed(3));}
let cur=-1;for(let i=0;i<overtures.length;i++){if(overtures[i].getBoundingClientRect().top<=vh*0.4)cur=i;}
if(cur!==curCh){curCh=cur;document.querySelectorAll('.spine .node').forEach(nd=>nd.classList.toggle('active',+nd.dataset.ch===cur));
$('#now').textContent=nowLabel(cur);if(cur>=0){markSeen(cur);localStorage.setItem('ir_pos',cur);}}
}
if(lenis)lenis.on('scroll',onScroll);
window.addEventListener('scroll',onScroll,{passive:true});window.addEventListener('resize',onScroll);requestAnimationFrame(onScroll);
if(doorIntroVideo)doorIntroVideo.addEventListener('loadedmetadata',onScroll);
/* LANGUAGE */
let langMenuOpen=false;
let searchReady=false;
function toggleLangMenu(e){e&&e.stopPropagation();langMenuOpen=!langMenuOpen;$('#langMenu').classList.toggle('on',langMenuOpen);}
function renderLanguageMenu(){
const menu=$('#langMenu');if(!menu)return;
menu.innerHTML='<div class="lm-h">Reading language</div>'+LANGUAGE_LIST.map(lang=>`<button class="lm" data-lang="${esc(lang.code)}" type="button"><b>${esc(lang.menuTitle||lang.label)}</b><span>${esc(lang.menuDescription||lang.label)}</span></button>`).join('');
menu.querySelectorAll('.lm').forEach(btn=>btn.addEventListener('click',()=>setLang(btn.dataset.lang,{track:true})));
}
function setLang(l,options={}){
if(l==='off')l=PRIMARY_LANGUAGE.code;
const cfg=LANGS[l]||PRIMARY_LANGUAGE;
l=cfg.code;
LANGUAGE_LIST.forEach(lang=>document.body.classList.toggle(`lang-${lang.code}`,lang.code===l));
document.body.dataset.readingLang=l;
document.querySelectorAll('.tr').forEach(el=>el.classList.toggle('on',el.dataset.lang===l));
document.querySelectorAll('#langMenu .lm').forEach(b=>b.classList.toggle('on',b.dataset.lang===l));
$('#langLab').textContent=cfg.shortLabel||cfg.code.toUpperCase();
localStorage.setItem('ir_lang',l);langMenuOpen=false;$('#langMenu').classList.remove('on');
if(searchReady)runSearch();
if(options.track)events.track('language_change',{lang:l});
}
document.addEventListener('click',e=>{if(langMenuOpen&&!e.target.closest('#langMenu')&&!e.target.closest('.lang-btn'))toggleLangMenu();});
(function(){let l=PRIMARY_LANGUAGE.code;try{l=localStorage.getItem('ir_lang')||PRIMARY_LANGUAGE.code;}catch(e){}setLang(l);})();
/* SKIM */
function toggleSkim(){const on=document.body.classList.toggle('skim');const b=$('#skimBtn');if(b)b.textContent=on?'Full reading view':'Skim view';}
/* OVERLAYS */
function toggleContents(){const c=$('#contents');const open=c.classList.toggle('on');document.body.classList.toggle('lock',open);if(open)events.track('contents_open');if(lenis){open?lenis.stop():lenis.start();}}
/* MOBILE NOTICE */
function closeMobileNotice(){
const m=$('#mobileNotice');if(!m)return;
m.classList.remove('on');
document.body.classList.remove('mobile-notice-open');
try{localStorage.setItem('ir_mobile_notice_v1','1');}catch(e){}
events.track('mobile_notice_continue');
if(lenis&&!document.querySelector('.ov-screen.on'))lenis.start();
}
(function initMobileNotice(){
const m=$('#mobileNotice'), btn=$('#mobileNoticeClose');if(!m||!btn)return;
let seenNotice=false;try{seenNotice=localStorage.getItem('ir_mobile_notice_v1')==='1';}catch(e){}
const narrow=window.matchMedia('(max-width: 760px)').matches;
const compactTouch=window.matchMedia('(pointer: coarse) and (max-width: 920px)').matches;
if((narrow||compactTouch)&&!seenNotice){
setTimeout(()=>{
if(document.querySelector('.ov-screen.on'))return;
m.classList.add('on');
document.body.classList.add('mobile-notice-open');
events.track('mobile_notice_seen',{viewport:'mobile'},{oncePerVisitor:true,onceKey:'v1'});
if(lenis)lenis.stop();
btn.focus({preventScroll:true});
},900);
}
btn.addEventListener('click',closeMobileNotice);
m.addEventListener('click',e=>{if(e.target===m)closeMobileNotice();});
})();
/* JUMP + SEARCH */
function jumpTo(rec){const el=rec.jump==='ch'?$('#ch-'+rec.ci):document.getElementById('idea-'+rec.ci+'-'+rec.ii);if(!el)return;revealArticle(el);const dest=rec.jump==='note'||rec.jump==='idea'?(el.querySelector('.sec-prose')||el):el;setTimeout(()=>{scrollTo(dest,-70);if(rec.jump==='note'&&rec.fn)setTimeout(()=>flashNote(el,rec.fn),520);},60);}
const INDEX=[];
DATA.forEach((c,ci)=>{
INDEX.push({ci,ii:0,type:'Essence',text:textOf(c.essence),jump:'ch'});
c.ideas.forEach((idea,ii)=>{
INDEX.push({ci,ii,type:'Title',text:textOf(idea.take),jump:'idea'});
idea.paragraphs.forEach(p=>{
INDEX.push({ci,ii,type:'Passage',text:stripFn(textOf(p.text)),jump:'idea'});
translationEntries(p.text).forEach(([lang,text])=>INDEX.push({ci,ii,type:lang.label,text,jump:'idea',translation:lang.code}));
});
(idea.footnotes||[]).forEach(f=>{
INDEX.push({ci,ii,type:'Note '+f.n,text:textOf(f.text),jump:'note',fn:f.n});
translationEntries(f.text).forEach(([lang,text])=>INDEX.push({ci,ii,type:`${lang.label} note ${f.n}`,text,jump:'note',fn:f.n,translation:lang.code}));
});
});
});
searchReady=true;
function snippet(text,q){const i=text.toLowerCase().indexOf(q);const a=Math.max(0,i-58),b=Math.min(text.length,i+q.length+90);let s=(a>0?'… ':'')+text.slice(a,b)+(b<text.length?' …':'');s=esc(s);const re=new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','ig');return s.replace(re,'<mark>$1</mark>');}
let seHits=[];
function runSearch(){
const q=$('#seInput').value.trim().toLowerCase();const box=$('#seResults');
if(q.length<2){box.innerHTML='<div class="se-empty">Type at least two characters…</div>';seHits=[];return;}
seHits=INDEX.filter(r=>r.text.toLowerCase().includes(q));
const order={'Title':0,'Essence':1,'Passage':2};
seHits.sort((a,b)=>(order[a.type]??4)-(order[b.type]??4));
const shown=seHits.slice(0,40);
if(!shown.length){box.innerHTML='<div class="se-empty">No matches for “'+esc(q)+'”.</div>';return;}
box.innerHTML=shown.map((r,k)=>`<button class="se-res" data-k="${k}"><div class="se-where">${pad(r.ci+1)} · ${renderLocalized(DATA[r.ci].title)} <span class="tag">${esc(r.type)}</span></div><div class="se-snip${r.translation?' translation lang-'+r.translation:''}">${snippet(r.text,q)}</div></button>`).join('')
+(seHits.length>40?`<div class="se-empty" style="padding:24px 0;font-size:14px">+ ${seHits.length-40} more — refine your search</div>`:'');
box.querySelectorAll('.se-res').forEach(el=>el.onclick=()=>{const r=shown[+el.dataset.k];events.track('search_result_jump',{query_length:q.length,result_count:seHits.length,result_type:r.type});toggleSearch();setTimeout(()=>jumpTo(r),130);});
}
function toggleSearch(){const s=$('#search');const open=s.classList.toggle('on');document.body.classList.toggle('lock',open);if(open)events.track('search_open');if(lenis){open?lenis.stop():lenis.start();}if(open){setTimeout(()=>$('#seInput').focus(),60);runSearch();}}
$('#seInput').addEventListener('input',runSearch);
/* RESUME */
let pos=null;try{pos=localStorage.getItem('ir_pos');}catch(e){}
const resumeIndex=pos!==null?+pos:NaN;
const canResume=Number.isFinite(resumeIndex)&&resumeIndex>0&&resumeIndex<DATA.length;
function resume(){if(canResume)scrollToCh(resumeIndex);}
const heroPrimary=$('#heroPrimary');
if(heroPrimary){
if(canResume){heroPrimary.textContent=`Resume · ${textOf(DATA[resumeIndex].title)}`;heroPrimary.onclick=()=>{events.track('resume_reading',{chapter:resumeIndex,title:textOf(DATA[resumeIndex].title)});resume();};}
else{heroPrimary.textContent='Start reading';heroPrimary.onclick=()=>{events.track('start_reading',{target_chapter:0});scrollToCh(0);};}
}
paintSeen();
/* ============ HIGHLIGHTS ============ */
function toggleHighlights(){}
(function(){
const supportsHL = ('highlights' in CSS) && typeof Highlight==='function';
let USER=[]; try{USER=JSON.parse(localStorage.getItem('ir_user_hl')||'[]');}catch(e){}
let NOTES=[]; try{NOTES=JSON.parse(localStorage.getItem('ir_notes')||'[]');}catch(e){}
let uid=USER.reduce((m,h)=>Math.max(m,h.id||0),0);
let nid=NOTES.reduce((m,n)=>Math.max(m,n.id||0),0);
const saveUser=()=>localStorage.setItem('ir_user_hl',JSON.stringify(USER));
const saveNotes=()=>localStorage.setItem('ir_notes',JSON.stringify(NOTES));
const saveRemoved=()=>localStorage.setItem('ir_ai_removed',JSON.stringify([...AIREMOVED]));
/* --- character-offset helpers within a .en root --- */
function textNodes(root){const out=[];const w=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;while(n=w.nextNode())out.push(n);return out;}
function offsetOf(root,node,off){const r=document.createRange();r.selectNodeContents(root);try{r.setEnd(node,off);}catch(e){return 0;}return r.toString().length;}
function rangeFromOffsets(root,start,end){
const r=document.createRange();const nodes=textNodes(root);let acc=0,started=false;
for(const t of nodes){const len=t.nodeValue.length;
if(!started&&start<=acc+len){r.setStart(t,Math.max(0,start-acc));started=true;}
if(end<=acc+len){r.setEnd(t,Math.max(0,end-acc));return r;}
acc+=len;}
if(!started)r.setStart(root,0);
const last=nodes[nodes.length-1];if(last)r.setEnd(last,last.nodeValue.length);
return r;
}
function enByPid(pid){const p=document.querySelector('.pp[data-pid="'+pid+'"]');return p?p.querySelector('.en'):null;}
function closestEn(node){const el=node&&(node.nodeType===1?node:node.parentElement);return el?el.closest('.en'):null;}
/* --- paint user highlights via CSS Custom Highlight API --- */
const hl=supportsHL?new Highlight():null;
function paint(){if(!hl)return;hl.clear();for(const h of USER){const en=enByPid(h.pid);if(!en)continue;try{hl.add(rangeFromOffsets(en,h.start,h.end));}catch(e){}}CSS.highlights.set('il-user',hl);}
/* --- floating action bar on text selection --- */
const fab=ce('div','hl-fab');
fab.innerHTML='<button type="button" class="fab-hl"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16.5 4.5l3 3L8 19l-4 1 1-4 11.5-11.5z"/><path d="M14.5 6.5l3 3"/></svg>Highlight</button>'
+'<button type="button" class="fab-note"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16M4 10h16M4 15h9"/><path d="M14 19l2-2 3 3-2 2h-3z"/></svg>Note</button>';
document.body.appendChild(fab);
let pending=null;
const hideFab=()=>{fab.classList.remove('on');pending=null;};
function onSelect(){
if($('#highlights').classList.contains('on')){hideFab();return;}
const sel=window.getSelection();
if(!sel||sel.isCollapsed||!sel.rangeCount){hideFab();return;}
const range=sel.getRangeAt(0);
const en=closestEn(range.startContainer), enEnd=closestEn(range.endContainer);
if(!en||en!==enEnd){hideFab();return;}
const start=offsetOf(en,range.startContainer,range.startOffset);
const end=offsetOf(en,range.endContainer,range.endOffset);
if(end-start<2){hideFab();return;}
pending={pid:en.closest('.pp').dataset.pid,start,end,text:sel.toString()};
const rc=range.getBoundingClientRect();
let top=rc.top-46;if(top<60)top=rc.bottom+12;
fab.style.left=clamp(rc.left+rc.width/2,64,innerWidth-64)+'px';
fab.style.top=top+'px';
fab.classList.add('on');
}
document.addEventListener('mouseup',()=>setTimeout(onSelect,10));
document.addEventListener('selectionchange',()=>{const s=window.getSelection();if(!s||s.isCollapsed)hideFab();});
document.addEventListener('mousedown',e=>{if(!e.target.closest('.hl-fab'))hideFab();if(!e.target.closest('.remove-pop'))hideRemove();
if(editingNote!=null&&wideEnough()&&!e.target.closest('.note-card')&&!e.target.closest('.hl-fab'))commitOpenEditor();});
window.addEventListener('scroll',hideFab,{passive:true});
if(lenis)lenis.on('scroll',hideFab);
fab.querySelector('.fab-hl').addEventListener('click',()=>{if(!pending)return;events.track('highlight_created',{pid:pending.pid,length:pending.end-pending.start});USER.push({...pending,id:++uid});saveUser();paint();window.getSelection().removeAllRanges();hideFab();buildPanel();});
fab.querySelector('.fab-note').addEventListener('click',()=>{if(!pending)return;const sel={...pending};events.track('note_created',{pid:sel.pid,length:sel.end-sel.start});window.getSelection().removeAllRanges();hideFab();addNote(sel);});
/* --- collect active highlights in reading order --- */
function gather(){
const ai=[];
DATA.forEach((c,ci)=>c.ideas.forEach((idea,ii)=>{const id=ci+'-'+ii;if(textOf(idea.quote)&&!AIREMOVED.has(id))ai.push({id,ci,ii,text:textOf(idea.quote)});}));
return {ai,user:USER,notes:NOTES};
}
/* --- panel --- */
const panel=$('#highlights'), body=$('#hlBody');
const esc2=s=>esc(s);
function item(kind,id,text,loc){return `<div class="hl-item ${kind}" data-kind="${kind}" data-id="${id}">`
+`<button class="hl-jump" type="button"><span class="hl-txt">${esc2(text)}</span><span class="hl-loc">${esc2(loc)}</span></button>`
+`<button class="hl-del" type="button" title="Remove highlight">✕</button></div>`;}
function locOfUser(u){const a=u.pid.split('-');return secNo(+a[0],+a[1]);}
function noteItemHTML(n){
const a=n.pid.split('-'), loc=secNo(+a[0],+a[1]);
const src=esc2((n.text||'').slice(0,46))+((n.text||'').length>46?'…':'');
return `<div class="hl-item note" data-kind="note" data-id="${n.id}">`
+`<button class="hl-jump" type="button"><span class="hl-txt">${n.note?esc2(n.note):'<span class=\'note-ph\'>Empty note</span>'}</span><span class="hl-loc">“${src}” · ${loc}</span></button>`
+`<button class="hl-del" type="button" title="Delete note">✕</button></div>`;
}
let panelTab='all';
function buildPanel(){
const {ai,user,notes}=gather();
const aiHtml=ai.length?ai.map(a=>item('ai',a.id,a.text,secNo(a.ci,a.ii))).join('')
:'<div class="hl-empty">No AI picks left — you’ve removed them all.</div>';
const userHtml=user.length?user.map(u=>item('user',u.id,u.text,locOfUser(u))).join('')
:'<div class="hl-empty">Nothing yet. Select any passage and choose “Highlight”.</div>';
const notesHtml=notes.length?notes.map(noteItemHTML).join('')
:'<div class="hl-empty">No notes yet. Select text and choose “Note”.</div>';
const counts={all:ai.length+user.length+notes.length,author:ai.length,mine:user.length,notes:notes.length};
const TABS=[['all','All','All'],['author','AI picks','AI'],['mine','My highlights','Highlights'],['notes','My notes','Notes']];
const tabsHtml='<div class="hl-tabs">'+TABS.map(t=>`<button class="hl-tab${panelTab===t[0]?' on':''}" type="button" data-tab="${t[0]}"><span class="t-full">${t[1]}</span><span class="t-min">${t[2]}</span><span class="tab-n">${counts[t[0]]}</span></button>`).join('')+'</div>';
const all=panelTab==='all';
const groups=[];
if(all||panelTab==='author')groups.push(`<div class="hl-group">${all?'<div class="hl-gh">AI picks <span>· key lines from the essay</span></div>':''}${aiHtml}</div>`);
if(all||panelTab==='mine')groups.push(`<div class="hl-group">${all?'<div class="hl-gh">My highlights</div>':''}${userHtml}</div>`);
if(all||panelTab==='notes')groups.push(`<div class="hl-group">${all?'<div class="hl-gh">My notes</div>':''}${notesHtml}</div>`);
body.innerHTML=tabsHtml
+`<div class="hl-top"><span class="hl-count">Click a line to jump · ✕ to remove</span><button class="hl-copy" type="button" id="hlCopy">Copy all</button></div>`
+groups.join('');
$('#hlCopy').onclick=doCopy;
body.querySelectorAll('.hl-tab').forEach(t=>t.onclick=()=>{panelTab=t.dataset.tab;buildPanel();});
body.querySelectorAll('.hl-item').forEach(el=>{
const k=el.dataset.kind, id=el.dataset.id;
el.querySelector('.hl-jump').onclick=()=>{if(k==='note'){toggleHighlights();setTimeout(()=>openNote(+id),150);}else jumpToHL(k,id);};
el.querySelector('.hl-del').onclick=ev=>{ev.stopPropagation();if(k==='note')deleteNote(+id);else delHL(k,id);};
});
}
function doCopy(){
const {ai,user,notes}=gather();
const text=[...ai.map(a=>a.text),...user.map(u=>u.text),...notes.map(n=>n.text+(n.note?'\n— '+n.note:''))].join('\n\n');
const done=()=>{const b=$('#hlCopy');if(b){b.textContent='Copied ✓';setTimeout(()=>b.textContent='Copy all',1400);}};
if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(text).then(done).catch(done);
else{const ta=ce('textarea');ta.value=text;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);done();}
}
function unwrap(m){const p=m.parentNode;if(!p)return;while(m.firstChild)p.insertBefore(m.firstChild,m);p.removeChild(m);if(p.normalize)p.normalize();}
function delHL(kind,id){
events.track('highlight_removed',{kind});
if(kind==='ai'){AIREMOVED.add(id);saveRemoved();const m=document.querySelector('mark.gl[data-ai="'+id+'"]');if(m)unwrap(m);paint();}
else{USER=USER.filter(h=>String(h.id)!==String(id));saveUser();paint();}
buildPanel();
}
function jumpToHL(kind,id){
toggleHighlights();
setTimeout(()=>{
if(kind==='ai'){const a=id.split('-');const el=document.getElementById('idea-'+a[0]+'-'+a[1]);
if(el){revealArticle(el);scrollTo(el.querySelector('.sec-prose')||el,-72);}}
else{const u=USER.find(h=>String(h.id)===String(id));if(u){const p=document.querySelector('.pp[data-pid="'+u.pid+'"]');
if(p){revealArticle(p.closest('.sec'));scrollTo(p,-130);}}}
},150);
}
toggleHighlights=function(){const open=panel.classList.toggle('on');document.body.classList.toggle('lock',open);if(open)events.track('highlights_open');if(lenis){open?lenis.stop():lenis.start();}if(open){hideFab();hideRemove();buildPanel();}};
/* --- click a highlighted sentence to remove it inline --- */
const pop=ce('button','remove-pop');pop.type='button';pop.innerHTML='Remove ✕';document.body.appendChild(pop);
let popAction=null;
const hideRemove=()=>{pop.classList.remove('on');popAction=null;};
function showRemove(x,y,fn){popAction=fn;let top=y-46;if(top<60)top=y+18;pop.style.left=clamp(x,64,innerWidth-64)+'px';pop.style.top=top+'px';pop.classList.add('on');}
pop.addEventListener('click',e=>{e.stopPropagation();const fn=popAction;hideRemove();if(fn)fn();});
function caretOffset(root,x,y){
let r=null;
if(document.caretRangeFromPoint)r=document.caretRangeFromPoint(x,y);
else if(document.caretPositionFromPoint){const c=document.caretPositionFromPoint(x,y);if(c){r=document.createRange();r.setStart(c.offsetNode,c.offset);}}
return r?offsetOf(root,r.startContainer,r.startOffset):-1;
}
document.addEventListener('click',e=>{
if($('#highlights').classList.contains('on'))return;
if(e.target.closest('.remove-pop')||e.target.closest('.hl-fab')||e.target.closest('.note-card')||e.target.closest('.note-scrim'))return;
if(e.target.closest('.fnref'))return; // let footnote clicks win
const sel=window.getSelection();if(sel&&!sel.isCollapsed)return; // mid text-selection
const m=e.target.closest&&e.target.closest('mark.gl[data-ai]');
if(m){showRemove(e.clientX,e.clientY,()=>delHL('ai',m.dataset.ai));return;}
const en=closestEn(e.target);
if(en){const p2=en.closest('.pp');const pid=p2&&p2.dataset.pid;const off=caretOffset(en,e.clientX,e.clientY);
const hn=off>=0&&NOTES.find(h=>String(h.pid)===pid&&off>=h.start&&off<h.end);
if(hn){openNote(hn.id);return;}
const hit=off>=0&&USER.find(h=>String(h.pid)===pid&&off>=h.start&&off<h.end);
if(hit){showRemove(e.clientX,e.clientY,()=>delHL('user',hit.id));return;}}
hideRemove();
});
window.addEventListener('scroll',hideRemove,{passive:true});
if(lenis)lenis.on('scroll',hideRemove);
/* ============ MARGIN NOTES (Google-Docs-style comments) ============ */
const noteHL=supportsHL?new Highlight():null;
function paintNotes(){if(!noteHL)return;noteHL.clear();for(const n of NOTES){const en=enByPid(n.pid);if(!en)continue;try{noteHL.add(rangeFromOffsets(en,n.start,n.end));}catch(e){}}CSS.highlights.set('il-note',noteHL);}
const rail=ce('div','note-rail');document.body.appendChild(rail);
const scrim=ce('div','note-scrim');document.body.appendChild(scrim);
let editingNote=null;
scrim.addEventListener('click',commitOpenEditor);
function railW(){return Math.min(252,(innerWidth-720)/2-44);}
const wideEnough=()=>railW()>=150;
function docTop(el){let y=0;while(el){y+=el.offsetTop;el=el.offsetParent;}return y;}
function anchorY(n){const en=enByPid(n.pid);if(!en)return null;const pp=en.closest('.pp');if(!pp)return null;
try{const rc=rangeFromOffsets(en,n.start,n.end).getBoundingClientRect();const pr=pp.getBoundingClientRect();return docTop(pp)+(rc.top-pr.top);}catch(e){return docTop(pp);}}
function srcLine(n){return '“'+esc2((n.text||'').slice(0,60))+((n.text||'').length>60?'…':'')+'”';}
function editorHTML(n){return '<div class="note-src">'+srcLine(n)+'</div><textarea class="note-ed" placeholder="Write your thought…">'+esc2(n.note||'')+'</textarea>'
+'<div class="note-row"><button type="button" class="note-del">Delete</button><button type="button" class="note-save">Save</button></div>';}
function viewHTML(n){return '<div class="note-src">'+srcLine(n)+'</div><div class="note-body">'+(n.note?esc2(n.note):'<span class="note-ph">Empty note</span>')+'</div>';}
function wireCard(card,n){
if(card.classList.contains('editing')){
const ta=card.querySelector('.note-ed');
const grow=()=>{ta.style.height='auto';ta.style.height=ta.scrollHeight+'px';if(!card.classList.contains('center'))layoutOnly();};
requestAnimationFrame(()=>{grow();ta.focus();ta.setSelectionRange(ta.value.length,ta.value.length);});
ta.addEventListener('input',grow);
card.querySelector('.note-save').onclick=()=>saveNote(n.id,ta.value);
card.querySelector('.note-del').onclick=()=>deleteNote(n.id);
} else card.onclick=()=>{editingNote=n.id;renderNotes();};
}
function renderNotes(){
rail.innerHTML='';
const wide=wideEnough(), center=editingNote!=null&&!wide;
scrim.classList.toggle('on',center);
if(!wide){
rail.style.display='none';
if(center){const n=NOTES.find(x=>x.id===editingNote);
if(n){rail.style.display='';const card=ce('div','note-card editing center');card.dataset.id=n.id;card.innerHTML=editorHTML(n);rail.appendChild(card);wireCard(card,n);}else editingNote=null;}
return;
}
rail.style.display='';
const w=railW(), left=(innerWidth+720)/2+24;
for(const n of NOTES){const y=anchorY(n);if(y==null)continue;
const editing=editingNote===n.id;
const card=ce('div','note-card'+(editing?' editing':''));card.dataset.id=n.id;card.dataset.y=y;
card.style.width=w+'px';card.style.left=left+'px';card.innerHTML=editing?editorHTML(n):viewHTML(n);
rail.appendChild(card);wireCard(card,n);}
layoutOnly();
}
function layoutOnly(){
const cards=[...rail.querySelectorAll('.note-card')].filter(c=>!c.classList.contains('center'));
cards.sort((a,b)=>(+a.dataset.y)-(+b.dataset.y));
let prev=-1e9;
for(const c of cards){const top=Math.max(+c.dataset.y,prev+12);c.style.top=top+'px';prev=top+c.offsetHeight;}
}
function addNote(sel){const n={id:++nid,pid:sel.pid,start:sel.start,end:sel.end,text:sel.text,note:''};NOTES.push(n);saveNotes();paintNotes();editingNote=n.id;renderNotes();}
function saveNote(id,val){const n=NOTES.find(x=>x.id===id);if(!n)return;val=(val||'').trim();if(!val){deleteNote(id);return;}events.track('note_saved',{note_length:val.length});n.note=val;saveNotes();editingNote=null;renderNotes();buildPanel();}
function deleteNote(id){events.track('note_deleted');NOTES=NOTES.filter(x=>x.id!==id);saveNotes();if(editingNote===id)editingNote=null;paintNotes();renderNotes();buildPanel();}
function commitOpenEditor(){if(editingNote==null)return;const ta=rail.querySelector('.note-card.editing .note-ed');if(ta)saveNote(editingNote,ta.value);else{editingNote=null;renderNotes();}}
function openNote(id){editingNote=id;const n=NOTES.find(x=>x.id===id);renderNotes();
if(n){const p=document.querySelector('.pp[data-pid="'+n.pid+'"]');if(p){revealArticle(p.closest('.sec'));scrollTo(p,-130);}}}
window.addEventListener('resize',renderNotes);
paint();paintNotes();renderNotes();
setTimeout(renderNotes,500);setTimeout(renderNotes,1600);
})();
Object.assign(window,{
toTop,
toggleTheme,
toggleLangMenu,
setLang,
toggleSearch,
toggleHighlights,
toggleContents
});
createFrontier();
import sampleReading from './sample-reading/data.js?v=1';
export const articles = {
[sampleReading.id]: sampleReading
};
export const defaultArticleId = sampleReading.id;
export function getArticle(id = defaultArticleId) {
return articles[id] || articles[defaultArticleId];
}
const article = {
id: "sample-reading",
slug: "sample-reading",
template: "immersive-reading",
title: {
en: "Sample Reading"
},
author: "Source Author",
description: {
en: "A bespoke learning space for close study, notes, and source attribution."
},
source: {
label: "Source material",
authorLine: "by Source Author",
url: "https://example.com/original",
urlLabel: "example.com/original"
},
languages: [
{
code: "en",
shortLabel: "EN",
label: "English",
menuTitle: "English",
menuDescription: "Original text only",
primary: true
}
],
defaultLanguage: "en",
door: {
ariaLabel: "Reading entrance",
video: "assets/door-entrance-8s-scrub.mp4",
poster: "assets/door-entrance-8s-poster.jpg",
purpose: "Independent learning space",
cue: "Enter"
},
hero: {
note: "A generated learning space for the source material.",
typedLine: "This source has a structure you can learn.",
primaryAction: "Start reading",
scrollCue: "Scroll",
originalLinkLabel: "See the original source"
},
footer: {
quote: {
en: "Replace this closing line with a final quote from the source."
},
quoteEmphasis: "",
credit: "Generated as a learning space for close study."
},
chapters: [
{
n: 1,
title: {
en: "The Opening Idea"
},
essence: {
en: "A concise orientation sentence that frames the first movement of the material."
},
words: 74,
ideas: [
{
take: {
en: "Start with the main promise of the source"
},
quote: {
en: "A short quoted line anchors the section."
},
paragraphs: [
{
text: {
en: "This sample paragraph shows the expected data shape. Replace it with the source text, a summary, or a translated study note."
}
},
{
text: {
en: "The reader builds search, navigation, highlights, notes, and bilingual display from this article data."
}
}
],
footnotes: []
}
]
},
{
n: 2,
title: {
en: "The Core Mechanism"
},
essence: {
en: "The second chapter explains how the central idea works and why it matters."
},
words: 63,
ideas: [
{
take: {
en: "Separate claims, examples, and implications"
},
quote: {
en: "The strongest sentence should be easy to find again."
},
paragraphs: [
{
text: {
en: "Each section can contain multiple paragraphs, optional footnotes, and optional translations. The reader builds search, navigation, highlights, and notes from this data."
}
}
],
footnotes: []
}
]
}
],
footnotes: {}
};
export default article;
// The fractal frontier — a particle sphere that grows fractal detail as you
// descend (knowledge has a fractal edge: pry open a crack, a world inside).
// Self-contained: renders behind everything, reads scroll + the DOM to decide
// when it should glow (over the hero / pinned overtures) and when to recede
// behind the reading text. It does NOT touch the reader's own JS.
// THREE is the global from the r128 CDN script (matches the proven immersive build).
const THREE = window.THREE;
const VERT = `
precision highp float;
uniform float uTime, uAmp, uFreq, uSize, uSeed;
attribute float aRand;
varying float vDisp; varying float vRand;
vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}
vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
float snoise(vec3 v){
const vec2 C=vec2(1.0/6.0,1.0/3.0); const vec4 D=vec4(0.0,0.5,1.0,2.0);
vec3 i=floor(v+dot(v,C.yyy)); vec3 x0=v-i+dot(i,C.xxx);
vec3 g=step(x0.yzx,x0.xyz); vec3 l=1.0-g; vec3 i1=min(g.xyz,l.zxy); vec3 i2=max(g.xyz,l.zxy);
vec3 x1=x0-i1+C.xxx; vec3 x2=x0-i2+C.yyy; vec3 x3=x0-D.yyy;
i=mod289(i);
vec4 p=permute(permute(permute(i.z+vec4(0.0,i1.z,i2.z,1.0))+i.y+vec4(0.0,i1.y,i2.y,1.0))+i.x+vec4(0.0,i1.x,i2.x,1.0));
float n_=0.142857142857; vec3 ns=n_*D.wyz-D.xzx;
vec4 j=p-49.0*floor(p*ns.z*ns.z);
vec4 x_=floor(j*ns.z); vec4 y_=floor(j-7.0*x_);
vec4 x=x_*ns.x+ns.yyyy; vec4 y=y_*ns.x+ns.yyyy; vec4 h=1.0-abs(x)-abs(y);
vec4 b0=vec4(x.xy,y.xy); vec4 b1=vec4(x.zw,y.zw);
vec4 s0=floor(b0)*2.0+1.0; vec4 s1=floor(b1)*2.0+1.0; vec4 sh=-step(h,vec4(0.0));
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy; vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
p0*=norm.x; p1*=norm.y; p2*=norm.z; p3*=norm.w;
vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0); m=m*m;
return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}
float fbm(vec3 p){ float v=0.0,a=0.5; for(int i=0;i<4;i++){ v+=a*snoise(p); p=p*2.0+13.7; a*=0.5;} return v; }
void main(){
vec3 pos=normalize(position);
float n=fbm(pos*uFreq + vec3(uSeed) + uTime*0.04);
float disp=n*uAmp;
vDisp=disp; vRand=aRand;
vec3 p=pos*(1.0+disp);
vec4 mv=modelViewMatrix*vec4(p,1.0);
gl_PointSize=uSize*(9.0/ -mv.z)*(0.55+0.9*aRand);
gl_Position=projectionMatrix*mv;
}`;
const FRAG = `
precision highp float;
uniform vec3 uColA, uColB; uniform float uBright;
varying float vDisp; varying float vRand;
void main(){
vec2 c=gl_PointCoord-0.5; float d=length(c); if(d>0.5) discard;
float a=smoothstep(0.5,0.0,d);
vec3 col=mix(uColA,uColB,smoothstep(-0.15,0.4,vDisp));
col*=uBright*(0.5+0.6*vRand);
gl_FragColor=vec4(col,a*0.5*(0.3+0.7*vRand));
}`;
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
export function createFrontier() {
const canvas = document.getElementById("gl");
if (!canvas || !THREE || typeof WebGLRenderingContext === "undefined") return;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 4.2);
// fibonacci sphere — even point distribution
const N = 9000;
const positions = new Float32Array(N * 3), rand = new Float32Array(N);
const gr = (1 + Math.sqrt(5)) / 2;
for (let i = 0; i < N; i++) {
const t = i / N, inc = Math.acos(1 - 2 * t), az = (2 * Math.PI * i) / gr;
positions[i * 3] = Math.sin(inc) * Math.cos(az);
positions[i * 3 + 1] = Math.sin(inc) * Math.sin(az);
positions[i * 3 + 2] = Math.cos(inc);
rand[i] = Math.abs((Math.sin(i * 127.1) * 43758.5453) % 1);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geo.setAttribute("aRand", new THREE.BufferAttribute(rand, 1));
const uniforms = {
uTime: { value: 0 }, uAmp: { value: 0.05 }, uFreq: { value: 1.1 },
uSize: { value: 1.5 }, uSeed: { value: 0 },
uColA: { value: new THREE.Color("#6a3c18") }, uColB: { value: new THREE.Color("#ecb46c") },
uBright: { value: 0.9 },
};
const mat = new THREE.ShaderMaterial({
uniforms, vertexShader: VERT, fragmentShader: FRAG,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
});
const group = new THREE.Group();
group.add(new THREE.Points(geo, mat));
scene.add(group);
let lightMode = false;
function setTheme(light) {
lightMode = light;
if (light) {
uniforms.uColA.value.set("#a87c46");
uniforms.uColB.value.set("#4a2c12");
mat.blending = THREE.NormalBlending;
} else {
uniforms.uColA.value.set("#6a3c18");
uniforms.uColB.value.set("#ecb46c");
mat.blending = THREE.AdditiveBlending;
}
mat.needsUpdate = true;
}
setTheme(document.body.classList.contains("light"));
addEventListener("il-theme", (event) => setTheme(Boolean(event.detail && event.detail.light)));
function resize() {
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
}
resize();
addEventListener("resize", resize);
const veilEl = document.getElementById("veil");
const target = { amp: 0.05, freq: 1.1, seed: 0, bright: 1.4, rotY: 0, camZ: 4.2, veil: 0 };
const cur = { ...target };
cur.camZ = 8.6; cur.bright = 0.08; cur.veil = 0.22; cur.amp = 0.02; // ignite + dolly-in on load
// glow over the frontier sections; recede behind the reading column.
function onScroll() {
const h = document.documentElement.scrollHeight - innerHeight;
const p = h > 0 ? scrollY / h : 0;
target.amp = 0.05 + p * 0.62; // knowledge grows fractal as you descend
target.freq = 1.1 + p * 2.8;
target.seed = p * 6.0;
target.camZ = 4.2 - p * 0.9; // descend INTO it
target.rotY = p * 1.35; // turn as you go, no idle spin
const vh = innerHeight;
let onFrontier = false, onReading = false;
document.querySelectorAll(".movement, .hero").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.top < vh * 0.55 && r.bottom > vh * 0.45) onFrontier = true;
});
document.querySelectorAll(".body-wrap, .foot").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.top < vh * 0.55 && r.bottom > vh * 0.45) onReading = true;
});
const readingActive = onReading;
document.body.classList.toggle("frontier-reading", readingActive);
target.veil = readingActive ? 0.7 : (onFrontier ? 0.02 : 0.34);
if (lightMode) {
target.bright = readingActive ? 0.34 : (onFrontier ? 0.92 : 0.58);
} else {
target.bright = readingActive ? 0.4 : (onFrontier ? 1.42 : 0.58);
}
}
addEventListener("scroll", onScroll, { passive: true });
onScroll();
function raf() {
uniforms.uTime.value += 0.016;
for (const k of ["amp", "freq", "seed", "bright", "camZ", "veil", "rotY"]) cur[k] += (target[k] - cur[k]) * 0.06;
uniforms.uAmp.value = cur.amp; uniforms.uFreq.value = cur.freq; uniforms.uSeed.value = cur.seed;
uniforms.uBright.value = cur.bright;
group.rotation.y = cur.rotY + Math.sin(uniforms.uTime.value * 0.05) * 0.04; // scroll-driven turn + faint sway
group.rotation.x = Math.sin(uniforms.uTime.value * 0.07) * 0.06; // gentle breath
camera.position.z = cur.camZ;
if (veilEl) veilEl.style.opacity = cur.veil.toFixed(3);
renderer.render(scene, camera);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
}
Content Model
Use this reference when generating or editing article data for the reader template.
Article Object
The generated file must default-export one JavaScript object:
const article = {
id: "kebab-case-id",
slug: "kebab-case-id",
template: "immersive-reading",
title: { en: "Article Title" },
author: "Author Name",
description: { en: "Short share-card description." },
source: {
label: "Source material",
authorLine: "by Author Name",
url: "https://example.com/original",
urlLabel: "example.com/original"
},
languages: [
{ code: "en", shortLabel: "EN", label: "English", menuTitle: "English", menuDescription: "Original text only", primary: true }
],
defaultLanguage: "en",
door: {
ariaLabel: "Reading entrance",
video: "assets/door-entrance-8s-scrub.mp4",
poster: "assets/door-entrance-8s-poster.jpg",
purpose: "Independent learning space",
cue: "Enter"
},
hero: {
note: "A generated learning space for the source material.",
typedLine: "This source has a structure you can learn.",
primaryAction: "Start reading",
scrollCue: "Scroll",
originalLinkLabel: "See the original source"
},
footer: {
quote: { en: "Closing quote." },
quoteEmphasis: "",
credit: "Generated as a learning space for close study."
},
chapters: [],
footnotes: {}
};
export default article;Keep the bundled door.video and door.poster paths unless the user provides custom entrance media. Do not replace the entrance with only a static hero image; the default video is what gives the opening its scroll-scrub motion.
Chapters And Sections
Use chapters[] for the major learning arc. In normal long-form content, prefer 5-14 chapters. Very short content can use 2-4 chapters.
Each chapter:
{
n: 1,
title: { en: "Chapter Title" },
essence: { en: "One sentence explaining what this chapter teaches." },
words: 420,
ideas: []
}Each section inside ideas[]:
{
take: { en: "Section title written as a clear claim" },
quote: { en: "Short memorable quote or excerpt." },
media: [
{
type: "image",
src: "assets/source/diagram-1.jpg",
alt: { en: "Diagram showing the source author's core model." },
caption: { en: "Original diagram from the source article." },
credit: "Source author"
}
],
paragraphs: [
{
text: { en: "Paragraph text." },
media: [
{
type: "image",
src: "https://example.com/source-image.jpg",
alt: { en: "Screenshot from the original source." },
caption: { en: "A source image kept in context." }
}
]
}
],
footnotes: [
{ n: 1, text: { en: "Note text." } }
]
}Source Media
If the original material includes meaningful media, do not silently drop it. Capture images, diagrams, screenshots, audio, video, and embeds that carry meaning for the reader.
Represent media on either a section (idea.media) or a specific paragraph (paragraph.media). Use:
{
type: "image", // image, video, audio, or link
src: "assets/source/example.jpg",
alt: { en: "Concise accessibility description." },
caption: { en: "Optional caption shown under the media." },
credit: "Optional source credit",
href: "https://example.com/original-media"
}Prefer local files under assets/source/ for stable images and diagrams. If downloading is not practical, use the original media URL as src and keep an href back to the source. Always include useful alt text for images. Use captions to explain why the media matters instead of decorating the page.
Bilingual Mode
When the user requests bilingual mode, add a language entry and add the same language code to localized fields:
languages: [
{ code: "en", shortLabel: "EN", label: "English", menuTitle: "English", menuDescription: "Original text only", primary: true },
{ code: "es", shortLabel: "ES", label: "Spanish", menuTitle: "English + Spanish", menuDescription: "Line-by-line study mode" }
]Then add translated values:
paragraphs: [
{
text: {
en: "Original paragraph.",
es: "Parrafo traducido."
}
}
]Do not claim the translation is official unless the source says so.
Structure Heuristics
- Prefer chapter titles that name the learning function, not vague moods.
- Use section
takevalues as claims the reader can remember. - Keep
quoteshort enough to work as a visual anchor. - If source paragraphs are long, preserve paragraph boundaries or summarize tightly depending on the reading goal.
- Compute
wordsfrom the primary-language text; approximate is acceptable, but do not leave it as zero.
Conversion Workflow
Use this when a source has weak or missing structure.
Suggested Process
1. Read the full source before writing article data. 2. Identify the central promise of the piece. 3. Mark natural turns in the argument, narrative, or tutorial. 4. Group turns into chapters. 5. Split each chapter into sections that each carry one memorable claim. 6. Choose one short quote/excerpt for each section. 7. Write essence for each chapter after sectioning; do not write it first. 8. Generate translations only after the primary structure is stable. 9. Validate with the bundled script.
Chapter Count
- 1,000-2,000 words: 3-5 chapters
- 2,000-6,000 words: 6-10 chapters
- 6,000+ words: 10-14 chapters
Avoid over-sectioning. The reader experience depends on each transition feeling meaningful.
Deployment
The generated reader is a static site. It can be deployed to Vercel, Netlify, GitHub Pages, or any static host.
Local Preview
node /path/to/skill/scripts/serve-reader.mjs /path/to/generated-reader --port 8791Open http://127.0.0.1:8791/.
Vercel
From the generated reader folder:
vercel --prodNo Upstash, Redis, or metrics environment variables are required. The template intentionally excludes production analytics.
Before Publishing
Run:
node /path/to/skill/scripts/validate-article-data.mjs src/articles/<article-id>/data.js
node /path/to/skill/scripts/smoke-test-reader.mjs .Then verify the share title, source attribution, desktop layout, mobile layout, theme toggle, search, highlights, and notes.
Design Contract
Use this before changing template UI or animation.
Preserve
- The first screen should focus on the source title, author, source link, and one primary reading action.
- Chapter openings should unfold slowly enough to read: title first, then code/section marker, then quote/body.
- The main body should appear near the visual center after chapter elements move away, not far below the fold.
- Light mode is the default. Dark mode remains available.
- Text must not collide with top navigation; preserve top fades and safe spacing.
- Keep search, contents, highlights, notes, language, and theme controls stable on hover.
- Do not add production analytics to the template.
Visual Tone
The template should feel like a serious learning space, not a marketing landing page. Use the existing typography, particle field, restrained color palette, and cinematic chapter transitions. Do not add decorative cards, gradient blobs, or extra background clutter.
Mobile
Mobile reading should work. Highlighting and note-taking can be limited, but the mobile notice must explain that the full experience is best on desktop.
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const scriptPath = fileURLToPath(import.meta.url);
const skillRoot = path.resolve(process.argv[2] || path.join(path.dirname(scriptPath), ".."));
const repoRoot = path.resolve(skillRoot, "../..");
const errors = [];
const required = [
"SKILL.md",
"agents/openai.yaml",
"references/content-model.md",
"references/conversion-workflow.md",
"references/design-contract.md",
"references/deployment.md",
"assets/reader-template/index.html",
"assets/reader-template/src/app.js",
"assets/reader-template/src/frontier.js",
"assets/reader-template/src/styles.css",
"assets/reader-template/src/articles/index.js",
"assets/reader-template/src/articles/sample-reading/data.js",
"scripts/scaffold-reader.mjs",
"scripts/serve-reader.mjs",
"scripts/smoke-test-reader.mjs",
"scripts/validate-article-data.mjs"
];
const forbidden = [
"python3 -m http.server",
"Reading Edition",
"Immersive Reading Edition",
"Cinematic Reader",
"READ_PAUL_GRAHAM",
"createReaderMetrics",
"/api/metrics",
"/api/pulse",
"great-work-eta",
"Noto Serif SC",
"body.lang-zh",
"lang-zh",
"--zhf",
"中文",
"Chinese"
];
const staleRepoPaths = [
"AGENTS.md",
"bin",
"setup",
"skills/immersive-reading/assets/reader-template/vercel.json",
"skills/immersive-reading/assets/reader-template/api"
];
for (const file of required) {
if (!fs.existsSync(path.join(skillRoot, file))) {
errors.push(`Missing required file: ${file}`);
}
}
checkStaleRepoPaths();
checkSkillFrontmatter();
scanForbiddenStrings();
checkScripts();
runValidation();
if (errors.length) {
console.error("Skill check failed:");
errors.forEach((error) => console.error(`- ${error}`));
process.exit(1);
}
console.log(JSON.stringify({
ok: true,
skillRoot,
checks: [
"required files",
"stale repo paths",
"SKILL.md frontmatter",
"portable string scan",
"script syntax",
"sample article validation",
"reader template smoke test"
]
}, null, 2));
function checkStaleRepoPaths() {
for (const rel of staleRepoPaths) {
if (fs.existsSync(path.join(repoRoot, rel))) {
errors.push(`Remove stale repo path: ${rel}`);
}
}
}
function checkSkillFrontmatter() {
const skillPath = path.join(skillRoot, "SKILL.md");
if (!fs.existsSync(skillPath)) return;
const text = fs.readFileSync(skillPath, "utf8");
if (!text.startsWith("---")) {
errors.push("SKILL.md must start with YAML frontmatter");
return;
}
if (!/^name:\s*immersive-reading\s*$/m.test(text)) {
errors.push("SKILL.md frontmatter must include name: immersive-reading");
}
if (!/^description:\s*\S.+$/m.test(text)) {
errors.push("SKILL.md frontmatter must include a non-empty description");
}
}
function scanForbiddenStrings() {
for (const file of walk(skillRoot)) {
if (!/\.(html|js|css|json|md|yaml|yml|mjs)$/.test(file)) continue;
const rel = path.relative(skillRoot, file);
if (rel === "scripts/check-skill.mjs" || rel === "scripts/smoke-test-reader.mjs") continue;
const text = fs.readFileSync(file, "utf8");
for (const needle of forbidden) {
if (text.includes(needle)) {
errors.push(`Forbidden string "${needle}" found in ${rel}`);
}
}
}
}
function checkScripts() {
const scriptsDir = path.join(skillRoot, "scripts");
if (!fs.existsSync(scriptsDir)) return;
for (const file of fs.readdirSync(scriptsDir)) {
if (!file.endsWith(".mjs")) continue;
runNode(["--check", path.join(scriptsDir, file)], `node --check scripts/${file}`);
}
}
function runValidation() {
const sample = path.join(skillRoot, "assets/reader-template/src/articles/sample-reading/data.js");
const template = path.join(skillRoot, "assets/reader-template");
if (fs.existsSync(sample)) {
runNode([
path.join(skillRoot, "scripts/validate-article-data.mjs"),
sample
], "validate sample article data");
}
if (fs.existsSync(template)) {
runNode([
path.join(skillRoot, "scripts/smoke-test-reader.mjs"),
template
], "smoke-test reader template");
}
}
function runNode(args, label) {
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
encoding: "utf8"
});
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
errors.push(`${label} failed${output ? `: ${output}` : ""}`);
}
}
function walk(dir) {
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walk(full));
else files.push(full);
}
return files;
}
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(here, "..");
const templateDir = path.join(skillRoot, "assets", "reader-template");
const args = parseArgs(process.argv.slice(2));
if (!args["article-data"] || !args.out) {
fail("Usage: node scripts/scaffold-reader.mjs --article-data <data.js> --out <output-dir>");
}
const articleDataPath = path.resolve(args["article-data"]);
const outDir = path.resolve(args.out);
const article = await loadArticle(articleDataPath);
if (!article?.id || !/^[a-z0-9-]+$/.test(article.id)) {
fail("article.id must exist and be kebab-case before scaffolding");
}
if (fs.existsSync(outDir) && fs.readdirSync(outDir).length && !args.force) {
fail(`Output directory is not empty: ${outDir}. Pass --force to overwrite template files.`);
}
fs.mkdirSync(outDir, { recursive: true });
fs.cpSync(templateDir, outDir, { recursive: true, force: true });
const articlesDir = path.join(outDir, "src", "articles");
fs.rmSync(articlesDir, { recursive: true, force: true });
fs.mkdirSync(path.join(articlesDir, article.id), { recursive: true });
fs.copyFileSync(articleDataPath, path.join(articlesDir, article.id, "data.js"));
fs.writeFileSync(path.join(articlesDir, "index.js"), articleRegistry(article.id));
const indexPath = path.join(outDir, "index.html");
let html = fs.readFileSync(indexPath, "utf8");
const title = textOf(article.title);
const description = textOf(article.description) || "A bespoke learning space for close study.";
html = html
.replace(/<title>[\s\S]*?<\/title>/, `<title>${escapeHtml(title)} · Immersive Reading</title>`)
.replace(/<meta name="description" content="[^"]*">/, `<meta name="description" content="${escapeAttr(description)}">`)
.replace(/<meta property="og:title" content="[^"]*">/, `<meta property="og:title" content="${escapeAttr(title)} · Immersive Reading">`)
.replace(/<meta property="og:description" content="[^"]*">/, `<meta property="og:description" content="${escapeAttr(description)}">`)
.replace(/<meta name="twitter:title" content="[^"]*">/, `<meta name="twitter:title" content="${escapeAttr(title)} · Immersive Reading">`)
.replace(/<meta name="twitter:description" content="[^"]*">/, `<meta name="twitter:description" content="${escapeAttr(description)}">`);
fs.writeFileSync(indexPath, html);
console.log(JSON.stringify({
ok: true,
outDir,
article: article.id,
next: [
`node ${path.join(skillRoot, "scripts", "serve-reader.mjs")} ${outDir} --port 8791`
]
}, null, 2));
function parseArgs(values) {
const result = {};
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (!value.startsWith("--")) continue;
const key = value.slice(2);
const next = values[index + 1];
if (!next || next.startsWith("--")) {
result[key] = true;
} else {
result[key] = next;
index += 1;
}
}
return result;
}
async function loadArticle(file) {
try {
const source = fs.readFileSync(file, "utf8");
const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}#${Date.now()}`;
const mod = await import(url);
return mod.default || mod.article;
} catch (error) {
fail(`Could not import article data: ${error.message}`);
}
}
function articleRegistry(articleId) {
const importName = articleId.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()).replace(/^[0-9]/, "article$&");
return `import ${importName} from './${articleId}/data.js?v=1';
export const articles = {
[${importName}.id]: ${importName}
};
export const defaultArticleId = ${importName}.id;
export function getArticle(id = defaultArticleId) {
return articles[id] || articles[defaultArticleId];
}
`;
}
function textOf(value) {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return "";
return value.en || Object.values(value)[0] || "";
}
function escapeHtml(value) {
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function escapeAttr(value) {
return escapeHtml(value).replace(/"/g, """);
}
function fail(message) {
console.error(message);
process.exit(1);
}
#!/usr/bin/env node
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
const args = parseArgs(process.argv.slice(2));
const root = path.resolve(args._[0] || ".");
const port = Number(args.port || 8791);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
fail(`Invalid port: ${args.port}`);
}
if (!fs.existsSync(path.join(root, "index.html"))) {
fail(`Expected a generated reader folder with index.html: ${root}`);
}
const server = http.createServer((request, response) => {
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
const filePath = resolveFile(root, url.pathname);
if (!filePath) {
response.writeHead(403);
response.end("Forbidden");
return;
}
fs.stat(filePath, (statError, stat) => {
if (statError || !stat.isFile()) {
response.writeHead(404);
response.end("Not found");
return;
}
serveFile(request, response, filePath, stat);
});
});
server.on("error", (error) => {
fail(`Could not start local preview server on 127.0.0.1:${port}: ${error.message}`);
});
server.listen(port, "127.0.0.1", () => {
console.log(`Serving ${root}`);
console.log(`Open http://127.0.0.1:${port}/`);
});
function resolveFile(rootDir, pathname) {
const decoded = decodeURIComponent(pathname);
const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
const full = path.resolve(rootDir, relative);
const withIndex = fs.existsSync(full) && fs.statSync(full).isDirectory()
? path.join(full, "index.html")
: full;
return withIndex.startsWith(rootDir + path.sep) || withIndex === rootDir ? withIndex : null;
}
function contentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".mp4": "video/mp4",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".png": "image/png",
".svg": "image/svg+xml",
".webm": "video/webm",
".webp": "image/webp"
}[ext] || "application/octet-stream";
}
function serveFile(request, response, filePath, stat) {
const type = contentType(filePath);
const range = request.headers.range;
if (!range) {
response.writeHead(200, {
"Content-Type": type,
"Accept-Ranges": "bytes",
"Content-Length": stat.size,
"Cache-Control": "no-store"
});
fs.createReadStream(filePath).pipe(response);
return;
}
const parsed = parseRange(range, stat.size);
if (!parsed) {
response.writeHead(416, {
"Content-Range": `bytes */${stat.size}`,
"Accept-Ranges": "bytes",
"Cache-Control": "no-store"
});
response.end();
return;
}
response.writeHead(206, {
"Content-Type": type,
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${parsed.start}-${parsed.end}/${stat.size}`,
"Content-Length": parsed.end - parsed.start + 1,
"Cache-Control": "no-store"
});
fs.createReadStream(filePath, parsed).pipe(response);
}
function parseRange(header, size) {
const match = /^bytes=(\d*)-(\d*)$/.exec(header);
if (!match || size < 1) return null;
let start;
let end;
if (match[1] === "" && match[2] === "") return null;
if (match[1] === "") {
const suffixLength = Number(match[2]);
if (!Number.isSafeInteger(suffixLength) || suffixLength < 1) return null;
start = Math.max(size - suffixLength, 0);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] === "" ? size - 1 : Number(match[2]);
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return null;
if (start < 0 || end < start || start >= size) return null;
return { start, end: Math.min(end, size - 1) };
}
function parseArgs(values) {
const result = { _: [] };
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (!value.startsWith("--")) {
result._.push(value);
continue;
}
const key = value.slice(2);
const next = values[index + 1];
if (!next || next.startsWith("--")) {
result[key] = true;
} else {
result[key] = next;
index += 1;
}
}
return result;
}
function fail(message) {
console.error(message);
process.exit(1);
}
#!/usr/bin/env node
import fs from "node:fs";
import { spawn } from "node:child_process";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(process.argv[2] || ".");
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const required = [
"index.html",
"src/app.js",
"src/frontier.js",
"src/styles.css",
"src/articles/index.js",
"assets/door-entrance-8s-scrub.mp4",
"assets/door-entrance-8s-poster.jpg"
];
const forbidden = [
"createReaderMetrics",
"/api/pulse",
"/api/metrics",
"READ_PAUL_GRAHAM",
"great-work-eta",
"ranli.me",
"paulgraham.com/greatwork",
"Cinematic Reader"
];
const errors = [];
for (const file of required) {
if (!fs.existsSync(path.join(root, file))) errors.push(`Missing ${file}`);
}
for (const file of walk(root)) {
if (!/\.(html|js|css|json|md)$/.test(file)) continue;
const text = fs.readFileSync(file, "utf8");
if (file.includes(`${path.sep}src${path.sep}articles${path.sep}`)) {
checkLocalSourceMedia(file, text);
}
for (const needle of forbidden) {
if (text.includes(needle)) {
errors.push(`Forbidden string "${needle}" found in ${path.relative(root, file)}`);
}
}
}
await checkPreviewServerRange();
if (errors.length) {
console.error("Reader smoke test failed:");
errors.forEach((error) => console.error(`- ${error}`));
process.exit(1);
}
console.log(JSON.stringify({ ok: true, root }, null, 2));
function walk(dir) {
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walk(full));
else files.push(full);
}
return files;
}
function checkLocalSourceMedia(file, text) {
const matches = text.matchAll(/["'`](assets\/source\/[^"'`]+)["'`]/g);
for (const match of matches) {
const rel = match[1];
if (!fs.existsSync(path.join(root, rel))) {
errors.push(`Missing source media ${rel} referenced in ${path.relative(root, file)}`);
}
}
}
async function checkPreviewServerRange() {
const serveScript = path.join(scriptDir, "serve-reader.mjs");
const videoPath = "assets/door-entrance-8s-scrub.mp4";
if (!fs.existsSync(path.join(root, videoPath))) return;
const port = await getAvailablePort();
const child = spawn(process.execPath, [serveScript, root, "--port", String(port)], {
stdio: ["ignore", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
try {
await waitForServer(port, child);
const response = await fetchWithTimeout(`http://127.0.0.1:${port}/${videoPath}`, {
headers: { Range: "bytes=0-31" }
});
const bytes = await response.arrayBuffer();
const contentRange = response.headers.get("content-range") || "";
const acceptRanges = response.headers.get("accept-ranges") || "";
if (response.status !== 206) {
errors.push(`Preview server must return 206 for video Range requests; got ${response.status}`);
}
if (acceptRanges.toLowerCase() !== "bytes") {
errors.push("Preview server must set Accept-Ranges: bytes for video files");
}
if (!/^bytes 0-31\/\d+$/.test(contentRange)) {
errors.push(`Preview server returned invalid Content-Range for video: ${contentRange || "(missing)"}`);
}
if (bytes.byteLength !== 32) {
errors.push(`Preview server returned ${bytes.byteLength} bytes for a 32-byte video Range request`);
}
} catch (error) {
errors.push(`Preview server Range check failed: ${error.message}${stderr ? ` (${stderr.trim()})` : ""}`);
} finally {
child.kill("SIGTERM");
}
}
function getAvailablePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : null;
server.close(() => {
if (port) resolve(port);
else reject(new Error("Could not allocate a local port"));
});
});
});
}
async function waitForServer(port, child) {
const started = Date.now();
while (Date.now() - started < 5000) {
if (child.exitCode !== null) {
throw new Error(`serve-reader exited with code ${child.exitCode}`);
}
try {
const response = await fetchWithTimeout(`http://127.0.0.1:${port}/`);
if (response.ok) return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error(`Timed out waiting for serve-reader on port ${port}`);
}
function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000);
return fetch(url, { ...options, signal: controller.signal }).finally(() => {
clearTimeout(timeout);
});
}
#!/usr/bin/env node
import fs from "node:fs";
const file = process.argv[2];
if (!file) {
fail("Usage: node scripts/validate-article-data.mjs <article-data.js>");
}
const article = await loadArticle(file);
const errors = [];
const warnings = [];
function fail(message) {
console.error(message);
process.exit(1);
}
async function loadArticle(path) {
try {
const source = fs.readFileSync(path, "utf8");
const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}#${Date.now()}`;
const mod = await import(url);
return mod.default || mod.article;
} catch (error) {
fail(`Could not import article data: ${error.message}`);
}
}
function textOf(value, lang = "en") {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return "";
return value[lang] || value.en || Object.values(value)[0] || "";
}
function requireText(path, value) {
if (!textOf(value).trim()) errors.push(`${path} must contain text`);
}
function wordCount(text) {
return text.trim().split(/\s+/).filter(Boolean).length;
}
function validateMedia(path, media) {
if (!media) return;
if (!Array.isArray(media)) {
warnings.push(`${path} should be an array when present`);
return;
}
media.forEach((item, index) => {
const itemPath = `${path}[${index}]`;
const src = item?.src || item?.url || item?.href;
if (!src) warnings.push(`${itemPath} is missing src/url/href`);
const type = item?.type || "";
if ((!type || type === "image") && !textOf(item?.alt).trim()) {
warnings.push(`${itemPath} image media should include alt text`);
}
});
}
if (!article || typeof article !== "object") errors.push("default export must be an article object");
if (!article.id || !/^[a-z0-9-]+$/.test(article.id || "")) errors.push("article.id must be kebab-case");
requireText("title", article.title);
requireText("description", article.description);
if (!article.author) warnings.push("article.author is empty");
if (!article.source?.label) warnings.push("article.source.label is empty");
if (!article.source?.url) warnings.push("article.source.url is empty");
const languages = Array.isArray(article.languages) ? article.languages : [];
if (!languages.length) errors.push("article.languages must include at least one language");
const primary = languages.find((lang) => lang.primary) || languages[0];
if (!primary?.code) errors.push("primary language must have a code");
const translationLangs = languages.filter((lang) => lang.code && lang.code !== primary?.code);
const chapters = Array.isArray(article.chapters) ? article.chapters : [];
if (!chapters.length) errors.push("article.chapters must include at least one chapter");
let paragraphCount = 0;
let sectionCount = 0;
let translatedParagraphs = 0;
let totalWords = 0;
chapters.forEach((chapter, chapterIndex) => {
const chapterPath = `chapters[${chapterIndex}]`;
requireText(`${chapterPath}.title`, chapter.title);
requireText(`${chapterPath}.essence`, chapter.essence);
const ideas = Array.isArray(chapter.ideas) ? chapter.ideas : [];
if (!ideas.length) errors.push(`${chapterPath}.ideas must include at least one section`);
ideas.forEach((idea, ideaIndex) => {
sectionCount += 1;
const ideaPath = `${chapterPath}.ideas[${ideaIndex}]`;
requireText(`${ideaPath}.take`, idea.take);
requireText(`${ideaPath}.quote`, idea.quote);
validateMedia(`${ideaPath}.media`, idea.media);
const paragraphs = Array.isArray(idea.paragraphs) ? idea.paragraphs : [];
if (!paragraphs.length) errors.push(`${ideaPath}.paragraphs must include at least one paragraph`);
paragraphs.forEach((paragraph, paragraphIndex) => {
paragraphCount += 1;
const paragraphPath = `${ideaPath}.paragraphs[${paragraphIndex}].text`;
requireText(paragraphPath, paragraph.text);
validateMedia(`${ideaPath}.paragraphs[${paragraphIndex}].media`, paragraph.media);
totalWords += wordCount(textOf(paragraph.text, primary?.code));
if (translationLangs.some((lang) => textOf(paragraph.text, lang.code))) {
translatedParagraphs += 1;
}
});
});
});
if (translationLangs.length && translatedParagraphs < paragraphCount) {
warnings.push(`Only ${translatedParagraphs}/${paragraphCount} paragraphs include translation text`);
}
if (errors.length) {
console.error("Article data validation failed:");
errors.forEach((error) => console.error(`- ${error}`));
if (warnings.length) {
console.error("\nWarnings:");
warnings.forEach((warning) => console.error(`- ${warning}`));
}
process.exit(1);
}
console.log(JSON.stringify({
ok: true,
id: article.id,
title: textOf(article.title, primary?.code),
chapters: chapters.length,
sections: sectionCount,
paragraphs: paragraphCount,
primaryWords: totalWords,
translationLanguages: translationLangs.map((lang) => lang.code),
warnings
}, null, 2));
Related skills
How it compares
Use instead of pasting an article into a chatbot for a TL;DR: it preserves the full source as an interactive reader with structure and study tools, rather than compressing it into a lossy summary.
FAQ
What is the Immersive Reading skill?
A Claude Code agent skill that turns any long-form source (essay, post, transcript, note, paper, or URL) into a self-contained interactive static reader with chapters, search, highlights, notes, source attribution, optional bilingual mode, and light/dark themes.
How do I install it?
In Claude Code run /plugin marketplace add ryannli/immersive-reading then /plugin install immersive-reading@immersive-reading, or install it as an open skill with npx skills add ryannli/immersive-reading. It also works with Codex, Cursor and other agents. MIT licensed.
Is it just an AI summarizer?
No - it is explicitly not a summary. It builds a co-reading space that keeps the full source and adds structure and study tools (chapters, quotes, search, highlights, notes) so you do the reading and thinking yourself, in a more engaging format.