Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion analyzer-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// the main-thread fallback produced context-aware ones.
// Keep these versions in step with app.html: a stale analyzer here means the worker scores
// with old detectors while the page believes it is running the new ones.
importScripts('prose-context.js?v=1', 'prose-norms.js?v=4', 'analyzer.js?v=29');
importScripts('prose-context.js?v=1', 'prose-norms.js?v=4', 'analyzer.js?v=30');

// Note: _currentVersion tracking here is structurally inert — the Worker JS runtime
// is single-threaded, so _currentVersion cannot change while Analyzer.analyze() runs
Expand Down
252 changes: 130 additions & 122 deletions analyzer.js

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions app.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>&#127807;</text></svg>">
<title>AuthorScrolls - Author's Best Buddy</title>
<link rel="stylesheet" href="styles.css?v=9">
<link rel="stylesheet" href="styles.css?v=10">
<link rel="stylesheet" href="styles-mobile.css?v=2">
<link rel="stylesheet" href="workspace-header.css?v=2">
<link rel="stylesheet" href="workspace-header.css?v=3">
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="assets/mammoth.browser.min.js"></script>
<script src="assets/jszip.min.js"></script>
Expand Down Expand Up @@ -439,7 +439,7 @@ <h2 style="font-family:Lora,serif;color:var(--gold-l);margin-bottom:.3rem">Choos

<script src="prose-context.js?v=1"></script>
<script src="prose-norms.js?v=4"></script>
<script src="analyzer.js?v=29"></script>
<script src="analyzer.js?v=30"></script>
<script src="grammar-enhance.js?v=17"></script>
<script src="manuscript-parser.js?v=3"></script>
<script src="book-intelligence.js?v=3"></script>
Expand All @@ -460,8 +460,8 @@ <h2 style="font-family:Lora,serif;color:var(--gold-l);margin-bottom:.3rem">Choos
<script data-intelligence-order="complete"></script>
<script src="storage.js?v=20"></script>
<script src="analysis-runner.js"></script>
<script src="app.js?v=28"></script>
<script src="workspace-header.js"></script>
<script src="app.js?v=32"></script>
<script src="workspace-header.js?v=2"></script>
<script src="mobile-panels.js?v=21"></script>

<aside id="intel-window" class="intel-window" aria-label="Book Intelligence" hidden>
Expand All @@ -471,6 +471,6 @@ <h2 style="font-family:Lora,serif;color:var(--gold-l);margin-bottom:.3rem">Choos
<div class="intel-askbox"><label for="intel-question">Ask your book</label><div><input id="intel-question" placeholder="What has Mara promised Daniel?"><button id="intel-ask">Ask</button></div><div id="intel-answer"></div></div>
</aside>

<script src="intelligence-window.js?v=10"></script>
<script src="intelligence-window.js?v=11"></script>
</body>
</html>
82 changes: 54 additions & 28 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,9 @@ function renderAll(){
_renderSubScores(r);
// Topbar genre shows the user-overridden label (secondary cleared on override)
if(activeGenre&&r.genre)r.genre.secondary=null;
$('top-status').textContent=(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 '+(r.manuscriptMode?.label||'');
// The genre select beside this already shows the chosen genre; repeat it only when the
// genre was detected rather than chosen, since the select then reads "Auto-detect".
$('top-status').textContent=(activeGenre?'':(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 ')+(r.manuscriptMode?.label||'');

renderSceneIntel(r);
// Book preview now renders on-demand when the Preview tab is activated — not here
Expand Down Expand Up @@ -865,7 +867,10 @@ function updateScoresOnly(r){
_renderSubScores(r);
// Topbar
$('top-wc').textContent=(r.totalWords||0).toLocaleString();
$('top-status').textContent=(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 '+(r.manuscriptMode?.label||'');
// Same rule as renderAll: the select already shows a chosen genre, so only a detected one is
// repeated here. (activeGenre is renderAll's local; it is not in scope in this function.)
const chosenGenre=($('genre-override')&&$('genre-override').value)||($('genre-select')&&$('genre-select').value)||'';
$('top-status').textContent=(chosenGenre?'':(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 ')+(r.manuscriptMode?.label||'');
drawGauge(r.overall||0);
$('top-score').textContent=r.overall||0;

Expand Down Expand Up @@ -1804,56 +1809,77 @@ function renderBlurbs(r){
const d=$('ed-blurbs');if(!d||!r)return;d.className='ms-page dark-page';
const b=r.blurbs;
if(!b||!b.available){
d.innerHTML='<div class="a-sec"><h3>Blurb Generator</h3><p style="color:var(--muted);font-size:.85rem">'+(b?.reason||'Upload a full manuscript to generate blurb suggestions.')+'</p><p style="color:var(--dim);font-size:.75rem;margin-top:.5rem">Blurbs are generated for books and manuscripts over 5,000 words. The engine extracts your story\'s core elements — protagonist, conflict, stakes — and assembles 5 variations in different styles.</p></div>';
d.innerHTML='<div class="a-sec"><h3>Blurb Generator</h3><p style="color:var(--muted);font-size:.85rem">'+(b?.reason||'Upload a full manuscript to generate blurb suggestions.')+'</p><p style="color:var(--dim);font-size:.75rem;margin-top:.5rem">Blurbs are generated for books and manuscripts over 5,000 words.</p></div>';
return;
}
let h='<div class="a-sec"><h3>Blurb Generator</h3>';
h+='<div style="display:flex;gap:.6rem;flex-wrap:wrap;margin-bottom:.75rem;font-size:.72rem;color:var(--muted)">';
h+='<span>Protagonist: <strong style="color:var(--gold-l)">'+esc(b.protagonist)+'</strong></span>';
if(b.antagonist)h+='<span>Antagonist: <strong style="color:var(--red)">'+esc(b.antagonist)+'</strong></span>';
h+='<span>Tone: <strong style="color:var(--text)">'+esc(b.toneDetected)+'</strong></span>';
h+='<span>Words: <strong>'+b.wordCount.toLocaleString()+'</strong></span>';
h+='</div></div>';

// 6-question framework breakdown
const tone=b.toneRates?Object.entries(b.toneRates).map(([k,v])=>k+' '+v+'/1K').join(' \u00B7 ')+(b.toneDetected?' \u2192 '+b.toneDetected:''):'';
let h='';
if(b.kind==='material'){
// Nonfiction: material by role, from the author's own sentences. No protagonist, no crisis.
h+='<div class="a-sec"><h3>Blurb material</h3><div class="bm-meta"><span>Words: <strong>'+b.wordCount.toLocaleString()+'</strong></span>'+(tone?'<span title="Tone vocabulary per 1,000 words">Tone vocabulary: '+esc(tone)+'</span>':'')+'</div><p class="bm-note">'+esc(b.note||'')+'</p></div>';
const section=(title,rows,empty)=>'<div class="a-sec"><h3>'+esc(title)+'</h3>'+(rows.length?rows.map(row=>'<div class="bm-row"><div class="bm-text">\u201C'+esc(row.text)+'\u201D</div><div class="bm-src">'+esc(row.section||'')+'</div></div>').join(''):'<p class="bm-empty">'+esc(empty)+'</p>')+'</div>';
for(const key of ['promise','problem','audience','authorStake']){
const m=b.material[key];if(!m)continue;
h+=section(m.label,m.rows,'No sentence in the manuscript matched this role. If the book does this, it does it in words the engine does not recognise \u2014 or it is worth writing.');
}
h+='<div class="a-sec"><h3>The method</h3><div id="bm-method" class="bm-empty">Reading chapters\u2026</div></div>';
h+='<div class="a-sec"><h3>The proof</h3><div id="bm-proof" class="bm-empty">Reading chapters\u2026</div></div>';
d.innerHTML=h;
_renderBlurbMaterialFromIntel(r);
return;
}
h+='<div class="a-sec"><h3>Blurb Generator</h3><div class="bm-meta">';
if(b.protagonist)h+='<span>Protagonist: <strong style="color:var(--gold-l)">'+esc(b.protagonist)+'</strong></span>';
else h+='<span>Protagonist: <em style="color:var(--dim)">no recurring name found</em></span>';
if(b.antagonist)h+='<span>Second lead: <strong style="color:var(--red)">'+esc(b.antagonist)+'</strong></span>';
if(tone)h+='<span title="Tone vocabulary per 1,000 words">Tone vocabulary: <strong style="color:var(--text)">'+esc(tone)+'</strong></span>';
h+='<span>Words: <strong>'+b.wordCount.toLocaleString()+'</strong></span></div></div>';
const fw=b.framework;
h+='<div class="a-sec"><h3>Story Framework <span style="font-size:.7rem;color:var(--muted);font-weight:400">(extracted from manuscript)</span></h3>';
h+='<div class="a-sec"><h3>Story Framework <span style="font-size:.7rem;color:var(--muted);font-weight:400">each answer says how it was chosen</span></h3>';
const questions=[
{q:'What does the character want?',a:fw.statusQuo},
{q:'How does it change?',a:fw.incitingIncident},
{q:'How does it get worse?',a:fw.conflict},
{q:'How do they try to fix it?',a:fw.attempt},
{q:'How does that make it worse?',a:fw.crisis},
{q:'What is at stake?',a:fw.stakes}
{q:'What does the character want?',a:fw.statusQuo},{q:'How does it change?',a:fw.incitingIncident},{q:'How does it get worse?',a:fw.conflict},
{q:'How do they try to fix it?',a:fw.attempt},{q:'How does that make it worse?',a:fw.crisis},{q:'What is at stake?',a:fw.stakes}
];
questions.forEach((q,i)=>{
h+='<div style="margin-bottom:.5rem"><div style="font-size:.72rem;color:var(--gold);font-weight:600;margin-bottom:.15rem">Q'+(i+1)+': '+q.q+'</div><div style="font-size:.8rem;color:'+(q.a?'var(--text)':'var(--dim)')+';padding-left:.6rem;border-left:2px solid var(--border)">'+(q.a?esc(q.a):'<em>Could not extract — try adding clearer story beats</em>')+'</div></div>';
const ans=q.a||{};
h+='<div style="margin-bottom:.55rem"><div style="font-size:.72rem;color:var(--gold);font-weight:600;margin-bottom:.15rem">Q'+(i+1)+': '+q.q+'</div>'
+'<div style="font-size:.8rem;color:'+(ans.text?'var(--text)':'var(--dim)')+';padding-left:.6rem;border-left:2px solid var(--border)">'+(ans.text?esc(ans.text):'<em>No sentence found</em>')+'</div>'
+'<div class="bm-basis">'+esc(ans.basis||'')+'</div></div>';
});
h+='</div>';

// 5 Blurb variations
b.blurbs.forEach((bl,i)=>{
h+='<div class="a-sec blurb-card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.4rem"><h3 style="margin:0">'+esc(bl.style)+'</h3><span style="font-size:.65rem;color:var(--muted)">'+bl.wordCount+' words</span></div>';
h+='<div class="a-sec blurb-card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.4rem"><h3 style="margin:0">'+esc(bl.style)+'</h3><span style="font-size:.65rem;color:var(--muted)">'+bl.wordCount+' words'+(bl.gaps?' \u00B7 '+bl.gaps+' gap'+(bl.gaps===1?'':'s')+' to write':'')+'</span></div>';
h+='<div style="font-size:.7rem;color:var(--dim);margin-bottom:.5rem;font-style:italic">'+esc(bl.description)+'</div>';
h+='<div class="blurb-text" id="blurb-'+i+'">'+esc(bl.text).replace(/\n/g,'<br>')+'</div>';
// Gaps are the beats the manuscript did not supply a sentence for; they are yours to write.
const body=esc(bl.text).replace(/\[([^\]]+)\]/g,'<span class="blurb-gap">$1</span>').replace(/\n/g,'<br>');
h+='<div class="blurb-text" id="blurb-'+i+'">'+body+'</div>';
h+='<div style="display:flex;gap:.3rem;margin-top:.5rem"><button class="btn-dark blurb-copy" data-idx="'+i+'" style="font-size:.7rem;padding:.25rem .6rem">Copy</button><button class="btn-dark blurb-edit" data-idx="'+i+'" style="font-size:.7rem;padding:.25rem .6rem">Edit</button></div>';
h+='</div>';
});

d.innerHTML=h;

// Copy buttons
d.querySelectorAll('.blurb-copy').forEach(btn=>{btn.addEventListener('click',()=>{
const idx=btn.dataset.idx;const el=$('blurb-'+idx);
navigator.clipboard.writeText(el.textContent);btn.textContent='Copied!';setTimeout(()=>{btn.textContent='Copy'},1500);
})});
// Edit buttons - make blurb editable
d.querySelectorAll('.blurb-edit').forEach(btn=>{btn.addEventListener('click',()=>{
const idx=btn.dataset.idx;const el=$('blurb-'+idx);
if(el.contentEditable==='true'){el.contentEditable='false';el.style.outline='';btn.textContent='Edit'}
else{el.contentEditable='true';el.style.outline='1px solid var(--gold-d)';el.style.outlineOffset='4px';el.focus();btn.textContent='Done'}
})});
}
// The method and the proof come from Document Intelligence's per-chapter extraction (explicit
// actions; attributed claims and personal evidence), the same rows the Intelligence window shows.
function _renderBlurbMaterialFromIntel(r){
if(typeof IntelligenceWindow==='undefined'||typeof IntelligenceWindow.data!=='function'){$('bm-method')?.remove();$('bm-proof')?.remove();return}
IntelligenceWindow.data(data=>{
const method=$('bm-method'),proof=$('bm-proof');if(!method&&!proof)return;
const nf=data&&data.intel&&data.intel.nonfiction;const chapters=(data&&data.parsed&&data.parsed.chapters)||[];
const title=id=>{const c=chapters.find(x=>x.id===id);return c?(c.title||c.id):''};
const rows=(list,n)=>(list||[]).slice(0,n).map(row=>'<div class="bm-row"><div class="bm-text">\u201C'+esc(row.text||row.evidence||'')+'\u201D</div><div class="bm-src">'+esc(title(row.chapterId))+'</div></div>').join('');
if(method)method.outerHTML=nf&&(nf.actions||[]).length?rows(nf.actions,5):'<p class="bm-empty">No explicit action was found. If the book gives the reader something to do, it is worth saying so in a sentence the cover can quote.</p>';
if(proof)proof.outerHTML=nf&&((nf.claims||[]).length||(nf.personalEvidence||[]).length)?rows(nf.claims,4)+rows(nf.personalEvidence,3):'<p class="bm-empty">No attributed claim or personal evidence was found.</p>';
},extractedText,r);
}

// TABS (bottom)
document.querySelectorAll('.btab').forEach(t=>{t.addEventListener('click',()=>{
Expand Down
74 changes: 74 additions & 0 deletions blurbs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use strict';
// Back-cover material must be the author's sentences with a stated basis, never lines the
// engine wrote, and a nonfiction book must not be handed a protagonist and a crisis.
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs');
globalThis.ProseContext=require('./prose-context');globalThis.ProseNorms=require('./prose-norms');
eval(fs.readFileSync(require.resolve('./analyzer'),'utf8')+'; globalThis.Analyzer=Analyzer;');

const INVENTED=/must face a choice|one chance to survive|lose everything|no turning back|Nothing will ever be the same|Some doors, once opened|The countdown has begun|has become a fight for/;

function selfHelp(){
const filler='Most people assume that effort is the whole story. Research generally suggests otherwise, and the pattern repeats in every city I have worked in. ';
let t='Introduction\n\nSit with me for a moment. If that’s you, take a seat. I wrote this for you.\n\nNow, perhaps you feel like you are doing everything right and still coming up short—carrying invisible weight, performing fine while struggling.\n\nWhen I arrived in the United States, I carried one bag and a pair of shoes.\n\n';
for(let c=1;c<=8;c++){t+='Chapter '+c+': Practice '+c+'\n\n';for(let i=0;i<40;i++)t+=filler+'\n\n';t+='Action: Write down one belief you want to test this week.\n\nAccording to a 2019 study, small habits compound.\n\n';}
return t;
}
// Paragraphed like a book (the framework slices by paragraph), with a short first chapter so
// the inciting line at the top of chapter 2 falls inside the first quarter.
function novel(){
const para=(s,n)=>{let out='';for(let i=0;i<n;i++){out+=s;if(i%10===9)out+='\n\n';}return out;};
let t='Chapter 1\n\nMara wanted the harbour and nothing else. Mara walked the pier each morning and counted the boats.\n\n';
t+=para('Mara watched the water while Daniel mended the nets, and the town went on as it always had. ',60);
t+='\n\nChapter 2\n\nThen one day a letter arrived for Mara, and Daniel would not say who had sent it.\n\n';
t+=para('Mara read it twice. Daniel said nothing. The harbour was quiet and the fear in the town grew like weather. ',120);
t+='\n\nChapter 3\n\nBut Daniel had lied about the letter, and Mara knew it.\n\n';
t+=para('Mara decided she must find the sender before the storm. Daniel followed her along the pier. ',120);
t+='\n\nChapter 4\n\nEverything Mara had built was lost in a single night.\n\n';
t+=para('Mara stood at the edge of the pier. If she left now she would lose the harbour, the boats, and Daniel. ',120);
return t;
}

test('a nonfiction book gets material by role, not a protagonist and a crisis',()=>{
const r=Analyzer.analyze(selfHelp(),'selfHelp');
const b=r.blurbs;
assert.equal(b.available,true);
assert.equal(b.kind,'material');
assert.ok(!('protagonist' in b),'no protagonist is invented for a self-help book');
assert.ok(!JSON.stringify(b).match(INVENTED),'no fiction filler anywhere');
assert.ok(b.material.promise.rows.some(row=>/I wrote this for you/.test(row.text)),JSON.stringify(b.material.promise));
assert.ok(b.material.problem.rows.some(row=>/coming up short/.test(row.text)));
assert.ok(b.material.authorStake.rows.some(row=>/When I arrived/.test(row.text)));
assert.ok(b.material.promise.rows.every(row=>typeof row.section==='string'),'every row says where it came from');
});

test('fiction answers carry the basis they were chosen on, and missing beats are gaps, not filler',()=>{
const r=Analyzer.analyze(novel(),'literary');
const b=r.blurbs;
assert.equal(b.kind,'framework');
assert.equal(b.protagonist,'Mara');
for(const key of ['statusQuo','incitingIncident','conflict','attempt','crisis','stakes']){
assert.ok(b.framework[key].basis.length>20,key+' states its basis');
}
assert.match(b.framework.statusQuo.basis,/wants, needs or hopes/);
assert.match(b.framework.incitingIncident.basis,/containing “(arrived|one day)”/i);
for(const bl of b.blurbs){
assert.ok(!INVENTED.test(bl.text),bl.style+' contains an invented line: '+bl.text);
assert.ok(Number.isFinite(bl.gaps));
}
// The three shapes that used to end on a stock closer now end on a gap the author writes;
// the other two may end on a manuscript sentence when every beat was found.
for(const style of ['Question Hook','Stakes Forward','Cinematic']){
const bl=b.blurbs.find(x=>x.style===style);
assert.ok(bl.gaps>=1,style+' leaves its closing line to the author');
assert.match(bl.text,/\[[^\]]+\]\s*$/,style+' ends on a gap, not an invented line');
}
});

test('tone is a rate per thousand words, and a handful of dark words in a long book is not "dark"',()=>{
let t='Chapter 1\n\n';for(let i=0;i<1500;i++)t+='She walked to the market and bought bread, then carried it home along the river. ';
t+='The fear passed. The dark came early. Fear again, and the dark, and a shadow, and one more fear.';
const b=Analyzer.analyze(t,'literary').blurbs;
assert.ok(b.toneRates&&Number.isFinite(b.toneRates.dark));
assert.ok(b.toneRates.dark<1,'six words in twenty thousand: '+b.toneRates.dark);
assert.equal(b.toneDetected,'no dominant tone vocabulary');
});
Loading
Loading