From 630eb62739bfc712e78473e2c7736764d44311f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:15:58 +0000 Subject: [PATCH 1/4] Homepage keeps a signed-in author signed in The homepage never loaded Firebase auth until someone clicked "Sign in", so a returning author always saw the signed-out nav, and the sign-in dialog opened even though their session was intact. The session itself was never lost. - The workspace leaves a small hint (ml_signed_in) on sign-in and clears it on sign-out. When the hint is present the homepage loads auth immediately, keeps the Sign in / Get started buttons out of sight until Firebase answers, and shows "Open workspace". Firebase alone decides whether a session exists; the hint only decides whether to ask before a click. - Sign in, Get started, and the demo banner's "Create a free account" link now wait for the restored session (up to 5s) and open the workspace instead of a dialog for someone already signed in. - The pricing page waits for the restored session instead of reading currentUser before the SDK has loaded it. - Smoke test: after using the workspace, the homepage shows "Open workspace", hides Sign in, and index.html#signup opens the workspace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- app.html | 2 +- index.html | 2 +- init.js | 6 ++++-- landing-init.js | 34 ++++++++++++++++++++++++++++------ pricing-init.js | 3 ++- pricing.html | 2 +- scripts/browser-smoke.js | 10 ++++++++++ 7 files changed, 47 insertions(+), 12 deletions(-) diff --git a/app.html b/app.html index 7ccc0b8..bb05cc2 100644 --- a/app.html +++ b/app.html @@ -402,7 +402,7 @@

Choos - + '; } h+=''; h+='
'; // filled by renderLeft: the one "Start here" card @@ -923,10 +923,10 @@ function _dimensionCards(r){ {k:'sh_evidence', name:'Evidence & Support', score:Math.round(sh.evidenceSupport||0), density:null,issues:0,weight:'5%', evidence:ev.evidence} ]; } - const p=r.plot||{};const q=p.quarters||[]; + const p=r.plot||{};const su=p.units||[]; const plotEv=isNF ? (p.thesisSignals||0)+' thesis \u00B7 '+(p.evidenceSignals||0)+' evidence \u00B7 '+(p.transitionSignals||0)+' transition \u00B7 '+(p.synthesisSignals||0)+' synthesis signals' - : (q.length?'tension per 1K by quarter '+q.map(x=>x.tensionPerK??'\u2014').join(' \u2192 '):''); + : (su.length?'scene share by '+(p.unitSource==='chapter'?'chapter':p.unitSource==='scene-break'?'scene':'segment')+' '+su.map(u=>u.sceneShare==null?'\u2014':u.sceneShare+'%').join(' \u2192 ')+(p.curve?' \u00B7 peak '+p.curve.peakUnit:''):''); const t=r.transitions||{}; const dl=r.dialogue||{}; const st=r.style||{}; @@ -1222,7 +1222,7 @@ function showDetail(cat){ const titles={plot:isNF?'Argument Structure':'Plot Structure',transitions:'Transitions',pacing:'Pacing',hook:'Hook Strength',style:'Style & Voice',dialogue:'Dialogue',showTell:'Show vs Tell',copy:'Copy Editing',grammar:'Grammar'}; const typeLabels={passive:'Passive Voice',adverb:'Adverb Overuse',cliche:'Cliche','weak-verb':'Weak Verb','show-tell':'Show vs Tell',wordy:'Wordy Phrase',repetition:'Repetition','sentence-length':'Long Sentence','confused-word':'Confused Word',grammar:'Grammar',pov:'POV Issue',hook:'Opening Problem',tags:'Dialogue Tags',conciseness:'Conciseness',showing:'Show Don\'t Tell',purpose:'Dialogue Purpose',naturalness:'Naturalness'}; - const catWhy={transitions:'This section measures whether adjacent paragraphs have explicit connective, lexical, or referential support. Unsupported boundaries are review candidates, not automatic errors.',pacing:'Pacing is described from observed manuscript segments rather than a borrowed quality score.',hook:'A strong opening hooks readers in the first page. Agents and editors often decide within 5 paragraphs.',style:'Style & Voice is described, not scored: POV, lexical diversity (MSTTR-100) and sentence and paragraph rhythm are measured for you to read, because none of them separates a strong manuscript from a weak one on its own.',showTell:'Telling emotions ("she felt sad") keeps readers at arm\'s length. Showing through action and sensory detail creates empathy.',copy:'Copy editing is the density of validated passive-voice, adverb, cliché, wordy-phrase, confused-word and repetition findings per 1,000 narrative words. Grammar is scored on its own.',grammar:'Grammar errors undermine credibility. Agents and editors stop reading when basics are wrong.',dialogue:'Dialogue reveals character, advances plot, and controls pacing. Every line should earn its place.',plot:isNF?'Strong nonfiction needs a clear thesis, evidence to support it, logical transitions, and a conclusion that synthesizes.':'Readers need forward momentum — clear stakes, rising tension, and consistent point of view.'}; + const catWhy={transitions:'This section measures whether adjacent paragraphs have explicit connective, lexical, or referential support. Unsupported boundaries are review candidates, not automatic errors.',pacing:'Pacing is described from observed manuscript segments rather than a borrowed quality score.',hook:'A strong opening hooks readers in the first page. Agents and editors often decide within 5 paragraphs.',style:'Style & Voice is described, not scored: POV, lexical diversity (MSTTR-100) and sentence and paragraph rhythm are measured for you to read, because none of them separates a strong manuscript from a weak one on its own.',showTell:'Telling emotions ("she felt sad") keeps readers at arm\'s length. Showing through action and sensory detail creates empathy.',copy:'Copy editing is the density of validated passive-voice, adverb, cliché, wordy-phrase, confused-word and repetition findings per 1,000 narrative words. Grammar is scored on its own.',grammar:'Grammar errors undermine credibility. Agents and editors stop reading when basics are wrong.',dialogue:'Dialogue reveals character, advances plot, and controls pacing. Every line should earn its place.',plot:isNF?'Strong nonfiction needs a clear thesis, evidence to support it, logical transitions, and a conclusion that synthesizes.':'Structure is measured from how the manuscript is built: its units, the share of each unit that is scene (action and dialogue) rather than summary, where the most scene-heavy unit sits and whether the book comes down from it, unit-length control against its own median, and whether the named cast persists. Stakes, causality and what a character wants are not measured.'}; // Canonical issue source per category — both card and detail derive from the same data const copyTypes=new Set(['passive','adverb','cliche','wordy','confused-word','repetition']); @@ -1370,16 +1370,22 @@ function renderDetailed(r){ plotRows.push(sr('Evidence / 1K words',plot.evidencePerK??0)); plotRows.push(sr('Logical transition signals',plot.transitionSignals??0)); plotRows.push(sr('Closing synthesis signals',plot.synthesisSignals??0)); - }else{ - plotRows.push(sr('Rising-action candidate',plot.risingActionCandidate?'Detected':'Not detected')); - plotRows.push(sr('Peak tension/action quarter',plot.peakQuarter??'N/A')); - plotRows.push(sr('Climax candidate',plot.climaxCandidate?'Detected':'Not detected')); - plotRows.push(sr('Resolution candidate',plot.resolutionCandidate?'Detected':'Not detected')); - if(Array.isArray(plot.quarters)&&plot.quarters.length){ - plotRows.push(sr('Tension signals',plot.quarters.reduce((n,q)=>n+(q.tensionSignals||0),0))); - plotRows.push(sr('Resolution signals',plot.quarters.reduce((n,q)=>n+(q.resolutionSignals||0),0))); - plotRows.push(sr('Action signals',plot.quarters.reduce((n,q)=>n+(q.actionSignals||0),0))); + }else if(plot.units&&plot.units.length){ + const src={chapter:'from chapter headings','scene-break':'from scene breaks',segment:'equal segments; no headings or scene breaks found'}[plot.unitSource]||''; + plotRows.push('Measured from how the manuscript is built: its units, how much of each is scene (action and dialogue) rather than summary, where the most scene-heavy unit sits, unit-length control, and cast persistence. Stakes and causality are not measured.'); + plotRows.push(sr('Units',plot.unitCount+' ('+src+')')); + plotRows.push(sr('Scene share by unit',plot.units.map(u=>u.sceneShare==null?'—':u.sceneShare+'%').join(' → '))); + const comp=plot.components||{}; + if(plot.curve){ + plotRows.push(sr('Most scene-heavy unit',plot.curve.peakUnit+' · '+plot.curve.peakShare+'% · '+Math.round(plot.curve.peakPosition*100)+'% of the way through')); + plotRows.push(sr('Final unit vs peak',plot.curve.release?'comes down '+(plot.curve.peakShare-plot.curve.lastUnitShare)+' points':'at the peak: no release measured')); + plotRows.push(sr('Scene-share range',plot.curve.amplitude+' points (first half mean '+plot.curve.firstHalfMean+'%, second half '+plot.curve.secondHalfMean+'%)')); } + if(comp.lengthControl)plotRows.push(sr('Unit length',comp.lengthControl.basis)); + if(comp.cast)plotRows.push(sr('Cast',comp.cast.basis)); + if(plot.notAssessed&&plot.notAssessed.length)plotRows.push(sr('Not assessed',plot.notAssessed.join('; '))); + }else if(plot.details){ + plotRows.push(plot.details); } plotRows.push(sr('Mode',(r.manuscriptMode?.label||'Unknown')+' (~'+(r.manuscriptMode?.estPages||0)+' pages)')); h+=secWithTip(plotLabel,scores.plot??null,plotRows,'plot',r); @@ -1526,11 +1532,14 @@ function evidenceTip(r,key,score){ return rec.length?top(rec):'No measured style-control weakness currently supports a recommendation.'; } if(key==='plot'){ - const rec=[]; - if(plot.hasRisingAction===false) rec.push('The plot detector did not find a rising-action pattern.'); - if(plot.hasClimax===false) rec.push('The full-manuscript detector did not find a climax signal.'); - if(plot.hasResolution===false) rec.push('The full-manuscript detector did not find a resolution signal.'); - return rec.length?top(rec):'No measured plot-structure weakness currently supports a recommendation.'; + const rec=[];const comp=plot.components||{};const cv=plot.curve; + if(comp.rhythm&&comp.rhythm.value<0.7) rec.push('Scene share barely changes from unit to unit ('+(cv?cv.amplitude:0)+'-point range): every unit is built the same way.'); + if(comp.shape&&cv&&cv.peakPosition<0.5) rec.push('The most scene-heavy unit is '+cv.peakUnit+', in the first half of the manuscript.'); + if(comp.shape&&cv&&!cv.release) rec.push('The final unit is as scene-heavy as the peak; the manuscript does not come down from its climax.'); + if(comp.lengthControl&&comp.lengthControl.outliers.length) rec.push('Unit length outliers: '+comp.lengthControl.outliers.slice(0,3).join(', ')+'.'); + if(comp.cast&&comp.cast.value<0.7) rec.push(comp.cast.basis+'.'); + if(plot.thesisSignals===0) rec.push('No explicit claim phrasing was found in the opening paragraphs.'); + return rec.length?top(rec):'No measured structural weakness currently supports a recommendation.'; } if(key==='dialogue'){ const fs=(r.dialogue?.findings||[]); diff --git a/book-intelligence.js b/book-intelligence.js index feb06b0..391b4ac 100644 --- a/book-intelligence.js +++ b/book-intelligence.js @@ -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']), diff --git a/detailed-data-integrity.test.js b/detailed-data-integrity.test.js index f83a0a0..48ed766 100644 --- a/detailed-data-integrity.test.js +++ b/detailed-data-integrity.test.js @@ -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',()=>{ diff --git a/structure.test.js b/structure.test.js new file mode 100644 index 0000000..62478ca --- /dev/null +++ b/structure.test.js @@ -0,0 +1,61 @@ +'use strict'; +// Plot Structure is measured from how a manuscript is built (units, scene-versus-summary +// share per unit, peak placement and release, unit-length control, cast persistence), not +// from a lexicon of tension words. These tests pin the ordering and the provenance. +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'}; +const quiet='She sat by the window and thought about the years that had passed since the house was sold, and about what her mother would have said of the garden now, and whether anyone remembered the roses. '; +const scene='"Run!" Mara shouted. Behind her Daniel grabbed the rope and pulled. The door slammed. Mara ducked, rolled, and came up with the knife. "Now," she said to Daniel. "Go now." They ran for the gate. '; +const unit=(sceneN,quietN)=>Array.from({length:quietN},()=>quiet.repeat(2)).concat(Array.from({length:sceneN},()=>scene.repeat(2))).join('\n\n'); +const book=profile=>profile.map((n,i)=>'Chapter '+(i+1)+'\n\n'+unit(n,8)).join('\n\n'); + +test('a late peak with a release outranks an early peak, which outranks a flat book',()=>{ + const late=Analyzer.analyzePlot(book([2,2,2,2,2,6,8,3]),'book',lit); + const early=Analyzer.analyzePlot(book([8,8,2,2,2,2,2,2]),'book',lit); + const flat=Analyzer.analyzePlot(book([4,4,4,4,4,4,4,4]),'book',lit); + assert.ok(late.score>early.score&&early.score>flat.score,[late.score,early.score,flat.score].join(' > ')); + assert.equal(late.unitSource,'chapter'); + assert.equal(late.unitCount,8); + assert.equal(late.curve.peakIndex,7,'the most scene-heavy unit is where the scenes are'); + assert.equal(late.curve.release,true); + assert.equal(early.curve.release,true); + assert.ok(early.curve.peakPosition<0.5); + assert.equal(flat.components.rhythm.value,0,'no variation is measured as no variation'); + assert.ok(!flat.components.shape&&flat.notAssessed.some(s=>/flat/.test(s)),'a flat book has no peak to place, and is not penalised twice'); + assert.match(late.components.cast.basis,/2 of 2 named characters recur .* (Mara|Daniel) present in 8 of 8 units/); +}); + +test('no headings or breaks: equal segments cut at paragraph boundaries, and no arc shape on a chapter',()=>{ + const t=unit(2,8)+'\n\n'+unit(6,8)+'\n\n'+unit(3,8); + const p=Analyzer.analyzePlot(t,'chapter',lit); + assert.equal(p.unitSource,'segment'); + assert.ok(p.unitCount>=3); + assert.ok(p.notAssessed.some(s=>/not a full manuscript/.test(s)),'peak placement is not judged on a single chapter'); + assert.ok(!p.components.shape); + assert.ok(Number.isFinite(p.score)); + const breaks=[unit(2,8),unit(6,8),unit(3,8),unit(1,8)].join('\n\n* * *\n\n'); + assert.equal(Analyzer.analyzePlot(breaks,'chapter',lit).unitSource,'scene-break'); +}); + +test('too little text is not measured, and every unit carries its provenance',()=>{ + const p=Analyzer.analyzePlot(unit(1,3),'excerpt',lit); + assert.equal(p.score,null); + assert.equal(p.applicable,false); + const full=Analyzer.analyzePlot(book([2,3,4,5]),'book',lit); + for(const u of full.units){ + assert.ok(u.label&&Number.isFinite(u.words)&&Number.isFinite(u.sceneShare)&&Number.isFinite(u.classifiedShare)); + assert.ok(Array.isArray(u.cast)); + } + assert.match(full.methodology,/not measured/); + assert.equal(full.hasRisingAction,null,'no keyword arc labels survive'); +}); + +test('the analysis pipeline feeds the structure score and its basis into scores and evidence',()=>{ + const r=Analyzer.analyze(book([2,2,3,6,8,3]),'literary'); + assert.equal(r.plot.arc,'structure'); + assert.equal(r.scores.plot,r.plot.score); + assert.ok(r.plot.units.length>=4); + assert.ok(r.plot.cast.lead,'the lead is named from the same character analysis the cast tab uses'); +});