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=30');
importScripts('prose-context.js?v=1', 'prose-norms.js?v=4', 'analyzer.js?v=32');

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

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions app.html
Original file line number Diff line number Diff line change
Expand Up @@ -398,11 +398,11 @@ <h2 style="font-family:Lora,serif;color:var(--gold-l);margin-bottom:.3rem">Choos
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-auth-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore-compat.js"></script>
<!-- Workspace demo (app.html?demo=selfhelp|novel): swaps in an in-memory Firebase double before init.js runs; inert otherwise. -->
<script src="demo-firebase.js?v=1"></script>
<script src="demo-firebase.js?v=2"></script>
<script src="demo-boot.js?v=1"></script>
<script src="firebase-config.js"></script>
<script src="legacy-recovery.js"></script>
<script src="init.js"></script>
<script src="init.js?v=2"></script>
<!-- COOKIE BANNER -->
<div id="cookie-banner" class="cookie-banner hidden">
<div class="cookie-inner">
Expand Down Expand Up @@ -442,10 +442,10 @@ <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=30"></script>
<script src="analyzer.js?v=32"></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>
<script src="book-intelligence.js?v=4"></script>
<script src="story-intelligence.js?v=2"></script>
<script src="continuity-intelligence.js?v=2"></script>
<script src="timeline-intelligence.js?v=3"></script>
Expand All @@ -463,7 +463,7 @@ <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=32"></script>
<script src="app.js?v=34"></script>
<script src="workspace-header.js?v=2"></script>
<script src="mobile-panels.js?v=21"></script>

Expand Down
92 changes: 52 additions & 40 deletions app.js

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions audit-fixes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';
// Fixes from the six-dimension audit (plot, transitions, copy, line, style, dialogue). Each
// test is the false finding or the punished-normal-prose case that motivated the change.
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 lit={primary:'literary'};

test('passive voice: a bare "-en" or "-ed" ending is not a participle',()=>{
const t='It was then that she saw him. The gate was open. He was even taller. There were seven of them. That was when it began. The men were ten miles off. The sky was red. She was naked to the wind. It was indeed late.';
assert.deepEqual(Analyzer.findPassiveVoice(t,lit).map(i=>i.text),[]);
const real='The letter was written by Mara. The bridge had been broken. The door was shut. He was driven home.';
assert.deepEqual(Analyzer.findPassiveVoice(real,lit).map(i=>i.text.split(/\s+by/)[0]),['was written','been broken','was shut','was driven']);
assert.deepEqual(Analyzer.findPassiveVoice('She was gone. The tree had fallen.',lit),[],'gone and fallen are intransitive');
});

test('grammar: the subjunctive, speech cadence and a double object are not errors',()=>{
const t='She spoke as if she were sure of it. I wish it were morning. If he were here, he would laugh. "Eh! you mustn\'t cry," she said. "My word! she\'s a plain little thing." They gave her her own way in everything.';
assert.deepEqual(Analyzer.findGrammarIssues(t).map(i=>i.text),[]);
const bad='She were tired. the road was long. He said the the word twice. "Go" he said.';
assert.deepEqual(Analyzer.findGrammarIssues(bad).map(i=>i.text).sort(),['" he said','. t','She were','the the']);
// The tag rule must read smart quotes too, and a comma before the closing quote is correct.
assert.deepEqual(Analyzer.findGrammarIssues('“Go” he said. "Stay," she said.').map(i=>i.text),['” he said']);
});

test('repetition: no synonym table, tags and function words are not echoes',()=>{
const t='"Yes," said Martha. "It tastes nice today," said Mary. There was a door in the wall. There was never a key to the door. She looked at the garden. She looked again.';
const reps=Analyzer.findRepetitions(t);
assert.ok(!reps.some(i=>/^(said|there|never)$/.test(i.text)),JSON.stringify(reps.map(i=>i.text)));
const door=reps.find(i=>i.text==='door');
assert.ok(door,'a content word that echoes is still reported');
assert.doesNotMatch(door.suggestion,/Try:|entrance|threshold/,'no fixed alternatives');
assert.equal(door.autoFix,false);
});

test('copy editing scores the set the workbench lists, without grammar',()=>{
const issues=[{type:'passive'},{type:'grammar'},{type:'grammar'},{type:'adverb'}];
assert.equal(Analyzer.scoreCopyEditing(issues,1000),Analyzer.scoreCopyEditing(issues.filter(i=>i.type!=='grammar'),1000));
});

test('transitions: dialogue exchanges and chapter headings are not judged as paragraph boundaries',()=>{
const prose=(n)=>Array.from({length:n},(_,i)=>i%2?'The garden waited behind the wall, and the wall was older than the house.':'She walked the length of the wall again, looking for the door in the ivy.').join('\n\n');
const exchange=Array.from({length:20},(_,i)=>i%2?'"No. Not tonight."':'"Are you coming?" Mara asked.').join('\n\n');
const t=prose(8)+'\n\n'+exchange+'\n\nChapter 2\n\n'+prose(8);
const r=Analyzer.analyzeTransitions(t);
assert.ok(r.skipped.dialogue>=20,'dialogue boundaries counted, not scored: '+JSON.stringify(r.skipped));
assert.ok(r.skipped.heading>=2,'heading boundaries counted, not scored');
assert.equal(r.measuredBoundaries,r.details.length);
assert.ok(r.details.every(d=>!/^["“]/.test(t.split(/\n\s*\n/)[d.toParagraph-1].trim())),'no measured boundary lands on a spoken line');
assert.ok(r.score>=90,'the prose itself is continuous: '+r.score);
const onlyDialogue=Analyzer.analyzeTransitions(exchange);
assert.equal(onlyDialogue.score,null,'nothing to measure is not a zero');
assert.match(onlyDialogue.reason,/dialogue/);
});

test('style: described not scored, and a first-person narrator is first person',()=>{
const fp=Array.from({length:30},()=>'My sister waited at the gate. I told her the truth and she laughed at me. My father said nothing. He looked at his hands and I looked at mine.').join('\n\n');
const s=Analyzer.analyzeStyle(fp);
assert.equal(s.score,null);
assert.equal(s.pov,'First Person');
assert.equal(s.pronouns.first,180,'"My" at a sentence start is counted (six first-person words per paragraph, not four)');
const third=Array.from({length:30},()=>'She waited at the gate. He told her the truth and she laughed at him. Her father said nothing.').join('\n\n');
assert.equal(Analyzer.analyzeStyle(third).pov,'Third Person');
const le=Analyzer.analyzeLineEditing(fp,'literary');
assert.equal(le.pov.score,null);
assert.ok(!le.findings.some(f=>f.type==='pov'),'no mixed-narration finding for a first-person narrator');
const r=Analyzer.analyze(fp,'literary');
assert.equal(r.scores.style,null);
assert.ok(Number.isFinite(r.overall),'the overall renormalises without style');
});

test('dialogue: a tag adverb needs a speech verb, and a handful of lines is not scored',()=>{
const lines=Array.from({length:12},(_,i)=>'"We leave at dawn and take the north road, whatever the guards say," '+(i%2?'Mara':'Jonas')+' said.');
const clean=Analyzer.analyzeDialogue(lines.join('\n\n')+'\n\nIt was "not only" late but "for only" the second time. "How highly" they rated it.',lit);
assert.equal(clean.adverbTags,0,'"not only" after a quote is not a tag adverb');
const tagged=Analyzer.analyzeDialogue(lines.join('\n\n')+'\n\n"Go," she said softly. "Stay," said Mara coldly.',lit);
assert.equal(tagged.adverbTags,2);
const few=Analyzer.analyzeDialogue('"Yes," she said.\n\n"Fine."\n\n"We leave at dawn," he said.',lit);
assert.equal(few.score,null);
assert.equal(few.applicable,false);
});
2 changes: 1 addition & 1 deletion book-intelligence.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const BookIntelligence = (() => {
const plot=analysis.plot||{}, pacing=analysis.pacing||{}, dialogue=analysis.dialogue||{};
const plotEvidence=plot.arc==='nonfiction'
? ['claim signals='+(plot.thesisSignals??0),'evidence signals='+(plot.evidenceSignals??0),'transition signals='+(plot.transitionSignals??0),'synthesis signals='+(plot.synthesisSignals??0)]
: ['peak quarter='+(plot.peakQuarter??'N/A'),'tension signals='+(plot.quarters||[]).reduce((n,q)=>n+(q.tensionSignals||0),0),'action signals='+(plot.quarters||[]).reduce((n,q)=>n+(q.actionSignals||0),0),'resolution signals='+(plot.quarters||[]).reduce((n,q)=>n+(q.resolutionSignals||0),0)];
: ['units='+(plot.unitCount??0)+(plot.unitSource?' ('+plot.unitSource+')':''),'scene share by unit='+(plot.units||[]).map(u=>u.sceneShare==null?'-':u.sceneShare).join('>'),'peak unit='+(plot.curve?plot.curve.peakUnit:'N/A'),...Object.entries(plot.components||{}).map(([k,c])=>k+'='+Math.round(c.value*100))];
const paceSegments=Array.isArray(pacing.segments)?pacing.segments:[];
return {
overall: metric(analysis.overall, 'Analyzer._computeScoreBundle', ['applicable dimensions only; weights renormalized']),
Expand Down
6 changes: 4 additions & 2 deletions demo-firebase.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@
}
};
const user={uid:'test-author',email:'writer@example.test',emailVerified:true,displayName:'Test Author',getIdToken:async()=>'test-token'};
const auth={currentUser:user,onAuthStateChanged:fn=>{queueMicrotask(()=>fn(auth.currentUser));return ()=>{};},
signOut:async()=>{auth.currentUser=null;},sendPasswordResetEmail:async()=>{},
// Like the real SDK, listeners hear the current state at once and again after sign-out.
const listeners=new Set();
const auth={currentUser:user,onAuthStateChanged:fn=>{listeners.add(fn);queueMicrotask(()=>{if(listeners.has(fn))fn(auth.currentUser);});return ()=>listeners.delete(fn);},
signOut:async()=>{auth.currentUser=null;listeners.forEach(fn=>fn(null));},sendPasswordResetEmail:async()=>{},
signInWithEmailAndPassword:async()=>{throw Object.assign(new Error('Invalid credentials'),{code:'auth/invalid-credential'});}
};
const firebase={apps:[],initializeApp(){this.apps.push({});},auth:()=>auth,firestore:()=>db};
Expand Down
26 changes: 19 additions & 7 deletions detailed-data-integrity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,20 @@ test('copy score is length-normalized and monotonic',()=>{
assert.equal(A.scoreCopyEditing(mk(10),10000),A.scoreCopyEditing(mk(20),20000));
});

test('fiction structure exposes measured quarter provenance',()=>{
const p=A.analyzePlot(fictionText(),{}, {primary:'fiction'});
assert.equal(p.applicable,true);
assert.equal(p.quarters.length,4);
assert.ok(p.quarters.every(q=>Number.isFinite(q.wordCount)&&Number.isFinite(q.tensionPerK)&&Number.isFinite(q.actionPerK)));
assert.ok(['measured-arc-signals','insufficient-data'].includes(p.arc));
test('fiction structure exposes measured unit provenance, and says what it could not assess',()=>{
const short=A.analyzePlot(fictionText(),'chapter',{primary:'fiction'});
assert.equal(short.applicable,false,'four short paragraphs are too little to measure structure');
assert.equal(short.score,null);
const chapters=Array.from({length:5},(_,i)=>'Chapter '+(i+1)+'\n\n'+Array.from({length:12},()=>fictionText()).join('\n\n')).join('\n\n');
const p=A.analyzePlot(chapters,'book',{primary:'fiction'});
assert.equal(p.arc,'structure');
assert.equal(p.unitSource,'chapter');
assert.equal(p.unitCount,5);
assert.ok(p.units.every(u=>Number.isFinite(u.words)));
// This context has no passage classifier: rhythm and shape are declared not assessed, not faked.
assert.ok(p.notAssessed.some(s=>/classifier unavailable/.test(s)));
assert.ok(p.components.lengthControl&&p.components.cast,'length control and cast persistence need no classifier');
assert.ok(Number.isFinite(p.score));
});

test('nonfiction structure uses argument evidence and never fiction substitutions',()=>{
Expand All @@ -53,7 +61,11 @@ test('nonfiction structure uses argument evidence and never fiction substitution
});

test('dialogue score reports observed candidates, not invented literary subscores',()=>{
const d=A.analyzeDialogue(fictionText(),{primary:'fiction'});
const one=A.analyzeDialogue(fictionText(),{primary:'fiction'});
assert.equal(one.applicable,false,'one line is too few to score a density');
assert.equal(one.score,null);
const lines=Array.from({length:12},(_,i)=>'"We leave at dawn and we take the north road, whatever the guards say," '+(i%2?'Mara':'Jonas')+' said.').join('\n\n');
const d=A.analyzeDialogue(fictionText()+'\n\n'+lines,{primary:'fiction'});
assert.equal(d.applicable,true);
assert.ok(Number.isFinite(d.score));
assert.ok(Number.isFinite(d.candidateCount));
Expand Down
5 changes: 3 additions & 2 deletions detailed-metrics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ test('a clean manuscript is not held under a hidden ceiling, and POV is N/A for
assert.equal(nf.flow.advisory,true);
assert.ok(nf.score>=85,'the composite is over the applicable dimensions only: '+nf.score);
const fic=Analyzer.analyzeLineEditing(Array(30).fill(plain).join('\n\n'),'literary');
assert.ok(fic.pov.score>=90,'consistent third person: '+fic.pov.score);
assert.ok(Number.isFinite(fic.flow.score),'fiction continuity is scored');
assert.equal(fic.pov.score,null,'POV shares are described, never scored');
assert.equal(fic.flow.score,null,'word overlap between sentences is not scored in fiction either');
assert.ok(Number.isFinite(fic.flow.unsupportedRate)&&fic.pov.shares,'the measurements are still reported');
});

test('self-help dimensions are rates: the same book five times over scores the same',()=>{
Expand Down
6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<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=Cormorant+Garamond:ital,wght@0,500;0,600;1,500;1,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="landing.css">
<link rel="stylesheet" href="landing.css?v=2">
<link rel="preload" as="image" href="assets/scroll-study.webp" media="(min-width: 701px)">
<script src="landing-init.js" defer></script>
<script src="landing-init.js?v=3" defer></script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
Expand All @@ -27,7 +27,7 @@
<div class="header-actions">
<button class="theme-toggle" id="theme-toggle" aria-label="Switch to light theme" title="Change theme">◐</button>
<div id="nl"><button class="text-button" id="nav-signin">Sign in</button><button class="button compact" id="nav-signup">Get started</button></div>
<div id="nu" class="hidden"><a class="button compact" href="app.html">Open workspace</a></div>
<div id="nu" class="hidden"><a class="button compact" href="app.html">Open workspace</a><button class="text-button" id="nav-signout">Sign out</button></div>
<button class="menu-toggle" id="menu-toggle" aria-expanded="false" aria-controls="mobile-nav" aria-label="Open navigation"><span></span><span></span></button>
</div>
</header>
Expand Down
6 changes: 4 additions & 2 deletions init.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ function clearPrivateStorage(preserveLegacy=false){
sessionStorage.clear();
}
firebase.auth().onAuthStateChanged(async function(user) {
if (!user) { window.location.href = 'index.html'; return; }
if (!user) { localStorage.removeItem('ml_signed_in'); window.location.href = 'index.html'; return; }
// Lets the homepage recognise a returning author before anyone clicks (see landing-init.js).
localStorage.setItem('ml_signed_in', '1');
// UI hint only. Server authorization uses ADMIN_UIDS, never email alone.
window.__isAdmin = user.emailVerified && ADMIN_EMAILS.includes((user.email||'').toLowerCase());
const previousOwner=localStorage.getItem('ml_storage_owner');
Expand Down Expand Up @@ -48,7 +50,7 @@ firebase.auth().onAuthStateChanged(async function(user) {
btn.onclick = async function() {
if(window.AuthorScrollsEditor?.save&&!await window.AuthorScrollsEditor.save())return;
// Preserve quarantined legacy drafts until the explicit recovery decision.
try{await firebase.auth().signOut();clearPrivateStorage(true);window.location.href='index.html';}
try{await firebase.auth().signOut();clearPrivateStorage(true);localStorage.removeItem('ml_signed_in');window.location.href='index.html';}
catch(_){alert('Sign-out failed. Please try again.');}
};
topRight.appendChild(btn);
Expand Down
Loading
Loading