diff --git a/analyzer-worker.js b/analyzer-worker.js index db821bf..ce9043f 100644 --- a/analyzer-worker.js +++ b/analyzer-worker.js @@ -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 diff --git a/analyzer.js b/analyzer.js index 9b64d68..06861f5 100644 --- a/analyzer.js +++ b/analyzer.js @@ -230,10 +230,17 @@ const Analyzer = { // PASSIVE VOICE DETECTION // ======================== PASSIVE_PATTERNS: [ - /\b(was|were|is|are|been|being|be)\s+(being\s+)?([\w]+ed|[\w]+en|built|caught|chosen|cut|done|drawn|driven|eaten|fallen|felt|found|forgotten|fought|given|gone|grown|heard|held|hidden|hit|hung|hurt|kept|known|laid|led|left|lent|let|lost|made|meant|met|paid|put|read|rid|run|said|sat|seen|sent|set|shot|shown|shut|sold|spent|spoken|stood|stuck|struck|sung|sworn|taken|taught|thought|thrown|told|torn|understood|woken|won|worn|written)\b/gi + // Irregular participles are listed by word. A bare "-en" ending is not one: "was then", + // "was open", "were seven" and "was even" are not passive. "gone" and "fallen" are + // intransitive and never passive either. + /\b(was|were|is|are|been|being|be)\s+(being\s+)?([\w]+ed|awoken|beaten|bitten|blown|broken|built|caught|chosen|cut|done|drawn|driven|eaten|felt|flown|forbidden|forgiven|forgotten|fought|found|frozen|given|gotten|grown|heard|held|hidden|hit|hung|hurt|kept|known|laden|laid|led|left|lent|let|lost|made|meant|met|mistaken|overtaken|overthrown|paid|proven|put|read|rewritten|rid|ridden|run|said|sat|seen|sent|set|sewn|shaken|shot|shown|shrunken|shut|smitten|sold|sown|spent|spoken|stolen|stood|stricken|struck|stuck|sung|sunken|sworn|swollen|taken|taught|thought|thrown|told|torn|trodden|understood|undertaken|withdrawn|woken|won|worn|woven|written)\b/gi ], // Non-passive -ed words that look passive but aren't (adjectives) PASSIVE_EXCEPTIONS: new Set([ + // Words that end in "-ed" but are not participles at all + 'red','bed','shed','sled','bred','need','indeed','seed','weed','reed','feed','speed', + 'sacred','hundred','kindred','hatred','naked','wicked','wretched','crooked','ragged', + 'rugged','jagged','dogged','aged','beloved','learned','blessed','cursed','wed', 'interested','excited','bored','tired','married','worried','surprised', 'pleased','satisfied','determined','experienced','advanced','complicated', 'dedicated','detailed','distinguished','educated','exhausted','fascinated', @@ -670,8 +677,19 @@ const Analyzer = { 'do','does','did','will','would','could','should','may','might','shall','can','that', 'this','these','those','i','you','he','she','we','they','me','him','her','us','them', 'my','your','his','our','their','not','no','so','as','if','then','than','into','up', - 'out','about','just','very','all','also','how','what','when','where','which','who']); - const synMap={said:['stated','replied','remarked','noted','added'],looked:['glanced','gazed','peered','watched','studied'],walked:['strode','moved','paced','strolled','crossed'],made:['created','crafted','formed','produced','built'],came:['arrived','appeared','emerged','approached','entered'],went:['headed','moved','traveled','crossed','departed'],turned:['pivoted','shifted','swung','rotated','spun'],stood:['rose','remained','lingered','waited','stayed'],knew:['understood','recognized','realized','sensed','grasped'],thought:['considered','wondered','reflected','believed','imagined'],felt:['sensed','experienced','noticed','detected','perceived'],took:['grabbed','seized','claimed','accepted','retrieved'],gave:['offered','handed','presented','provided','delivered'],started:['began','initiated','launched','commenced','opened'],seemed:['appeared','looked','sounded','suggested','indicated'],told:['informed','explained','revealed','instructed','described'],asked:['questioned','inquired','wondered','requested','demanded'],eyes:['gaze','stare','glance','look','vision'],face:['expression','features','countenance','visage','look'],hand:['grip','palm','fingers','fist','grasp'],head:['mind','thoughts','skull','brow','temple'],voice:['tone','words','speech','whisper','sound'],door:['entrance','doorway','threshold','entry','gate'],room:['chamber','space','quarters','hall','area'],time:['moment','occasion','instance','period','while'],back:['spine','rear','return','retreat','behind'],long:['extended','prolonged','lengthy','enduring','sustained'],dark:['dim','shadowed','unlit','gloomy','murky'],small:['little','slight','tiny','compact','modest'],found:['discovered','located','uncovered','encountered','spotted'],called:['named','summoned','addressed','hailed','dubbed'],people:['individuals','figures','crowd','group','folk'],world:['realm','domain','land','sphere','landscape'],place:['location','spot','position','site','area'],still:['motionless','calm','quiet','unmoving','yet'],words:['speech','language','phrases','remarks','terms'],thing:['object','matter','item','element','detail'],woman:['figure','lady','person','character','she'],before:['earlier','previously','prior','ahead','formerly'],every:['each','all','entire','whole','total'],never:['rarely','seldom','hardly','not once','at no point'],always:['constantly','perpetually','inevitably','forever','endlessly'],around:['surrounding','about','nearby','encircling','throughout']}; + 'out','about','just','very','all','also','how','what','when','where','which','who', + // Function words of four letters or more, and the invisible dialogue tags. Their + // recurrence is grammar, not a word-choice habit. + 'there','here','like','some','never','always','ever','mine','yours','ours','theirs', + 'herself','himself','itself','myself','yourself','themselves','ourselves','only','even', + 'much','many','more','most','such','other','others','over','under','onto','upon','from', + 'been','being','again','still','while','until','after','before','because','though', + 'although','whether','either','neither','both','each','every','quite','rather','really', + 'would','could','should','might','must','shall','will','have','does','done','said','asked', + 'something','nothing','anything','everything','someone','anyone','everyone','nobody']); + // No synonym table. A fixed list of alternatives ("said" → "stated, remarked") is not + // advice about this manuscript; the finding says where the echo is and leaves the word + // to the author. const seen = new Set(); // Names repeat because they are names. A capitalised word whose lowercase form never // occurs anywhere in this manuscript is treated as one and never raised; "Alice" is a @@ -713,12 +731,11 @@ const Analyzer = { const key = word + ':' + wordIdx; if (seen.has(key)) continue; seen.add(key); - const alts = synMap[word]; - const sugText = alts ? 'Try: ' + alts.slice(0, 3).join(', ') : 'Vary your word choice — try a synonym or restructure the sentence.'; issues.push({ type: 'repetition', text: word, index: wordIdx, length: word.length, severity: 'low', confidence: 0.85, message: `"${word}" repeated in consecutive sentences.`, - suggestion: sugText, + suggestion: `"${word}" also appears in the sentence before. Vary it if the echo is accidental; keep it if it is doing work.`, + autoFix: false, ...(parallel ? { rhetorical: 'parallel' } : {}) }); } @@ -1249,33 +1266,126 @@ const Analyzer = { }, // ======================== - // PLOT STRUCTURE ANALYSIS - // ======================== - analyzePlot(text, mode, genre) { + // STRUCTURE (fiction) + // ======================== + // The manuscript's skeleton, measured from how it is built rather than from a lexicon of + // tension words: its units (chapters, scene breaks, or equal segments when it has + // neither); how much of each unit is scene (action and dialogue, the modes a reader lives + // through in real time) against summary (reflection, description, exposition); where the + // most scene-heavy stretch sits and whether the book comes down from it; how the unit + // lengths hold to the book's own median; and whether the named cast persists across + // units. It does not measure stakes, causality or what a character wants: nothing + // lexical can, and the methodology says so. + _structureUnits(text, mode) { + const wordsIn = s => (s.match(/\b[\w\u2019'-]+\b/g) || []).length; + let m, marks = [], source = 'chapter'; + const headingRe = /^[ \t]*(?:(?:chapter|part|book)\s+(?:\d+|[ivxlc]+|[a-z]+(?:[- ][a-z]+)?)\b[^\n]{0,80}|(?:prologue|epilogue|interlude)\b[^\n]{0,80})$/gim; + const shortLabel = s => { const t = s.trim().replace(/\s+/g, ' '); if (t.length <= 36) return t; const cut = t.slice(0, 36); return cut.slice(0, Math.max(cut.lastIndexOf(' '), 12)) + '…'; }; + while ((m = headingRe.exec(text)) !== null) marks.push({ at: m.index, end: m.index + m[0].length, label: shortLabel(m[0]) }); + if (marks.length < 2) { + marks = []; source = 'scene-break'; + const breakRe = /\n[ \t]*(?:\*\s*\*\s*\*|#{1,3}|-{3,}|~{3,})[ \t]*\n/g; + let n = 1; + while ((m = breakRe.exec(text)) !== null) marks.push({ at: m.index, end: m.index + m[0].length, label: 'Scene ' + (++n) }); + } + if (marks.length >= 2) { + const units = []; + let cursor = 0, label = source === 'chapter' ? 'Opening' : 'Scene 1'; + for (const mk of marks) { + const body = text.slice(cursor, mk.at), w = wordsIn(body); + if (w >= 60) units.push({ label, start: cursor, end: mk.at, words: w }); + cursor = mk.end; label = mk.label; + } + const tail = text.slice(cursor), tw = wordsIn(tail); + if (tw >= 60) units.push({ label, start: cursor, end: text.length, words: tw }); + if (units.length >= 3) return { units, source }; + } + // Neither headings nor breaks: equal segments cut at paragraph boundaries. + const total = wordsIn(text); + const n = Math.max(3, Math.min(24, Math.round(total / (mode === 'book' ? 1500 : 400)))); + const paras = []; + const splitter = /\n\s*\n/g; let last = 0; + while ((m = splitter.exec(text)) !== null) { paras.push({ start: last, end: m.index }); last = m.index + m[0].length; } + paras.push({ start: last, end: text.length }); + const units = []; let acc = 0, uStart = 0, k = 1; + for (const p of paras) { + acc += wordsIn(text.slice(p.start, p.end)); + if (acc >= k * total / n && k < n) { units.push({ label: 'Segment ' + k, start: uStart, end: p.end, words: acc - (units.length ? units.reduce((a, u) => a + u.words, 0) : 0) }); uStart = p.end; k++; } + } + const used = units.reduce((a, u) => a + u.words, 0); + if (total - used >= 60) units.push({ label: 'Segment ' + k, start: uStart, end: text.length, words: total - used }); + return { units, source: 'segment' }; + }, + analyzePlot(text, mode, genre, characters) { if(this.isNonfiction(genre)) return this._analyzeArgumentStructure(text,mode); - const paragraphs=text.split(/\n\s*\n/).filter(p=>p.trim()); - if(paragraphs.length<3)return {score:null,applicable:false,arc:'insufficient-data',details:'Too little narrative text for structural measurement.',paragraphCount:paragraphs.length,quarters:[]}; - const qSize=Math.ceil(paragraphs.length/4); - const tensionRe=/\b(conflict|struggle|fight|danger|threat|problem|challenge|crisis|desperate|fear|terror|panic|attack|escape|chase|betray|confront|demand|risk|urgent|trapped|failed|loss|enemy|pressure)\b/gi; - const resolutionRe=/\b(resolved|forgive|forgiven|healed|returned|reunited|safe|calm|accepted|reconciled|settled|answered|solution|peace)\b/gi; - const actionRe=/\b(ran|rushed|grabbed|threw|struck|fought|attacked|escaped|chased|drove|pulled|pushed|fell|burst|charged|fired|slammed|crashed)\b/gi; - const quarters=[]; - for(let i=0;i<4;i++){ - const ps=paragraphs.slice(i*qSize,Math.min((i+1)*qSize,paragraphs.length)); - const txt=ps.join(' '), wc=(txt.match(/\b[\w’'-]+\b/g)||[]).length; - const tension=(txt.match(tensionRe)||[]).length, resolution=(txt.match(resolutionRe)||[]).length, action=(txt.match(actionRe)||[]).length; - quarters.push({quarter:i+1,paragraphStart:i*qSize+1,paragraphEnd:Math.min((i+1)*qSize,paragraphs.length),wordCount:wc,tensionSignals:tension,resolutionSignals:resolution,actionSignals:action,tensionPerK:+(tension/Math.max(wc,1)*1000).toFixed(2),resolutionPerK:+(resolution/Math.max(wc,1)*1000).toFixed(2),actionPerK:+(action/Math.max(wc,1)*1000).toFixed(2)}); - } - const curve=quarters.map(q=>q.tensionPerK+q.actionPerK*.5); - const risingCandidate=curve[1]>curve[0]; - const peak=Math.max(...curve),peakQuarter=curve.indexOf(peak)+1; - const climaxCandidate=peakQuarter===3||peakQuarter===4; - const resolutionCandidate=quarters[3].resolutionPerK>Math.max(quarters[0].resolutionPerK,quarters[1].resolutionPerK); - // Structural score reflects only measured arc-shape evidence. Labels remain candidates: - // deterministic lexical signals cannot prove a literary climax or resolution. - const measured=[risingCandidate,climaxCandidate,resolutionCandidate]; - const score=Math.round(measured.filter(Boolean).length/measured.length*100); - return {score,applicable:true,arc:'measured-arc-signals',methodology:'Quarter-normalized tension/action/resolution signals; structural labels are candidates, not facts.',quarters,paragraphCount:paragraphs.length,risingActionCandidate:risingCandidate,climaxCandidate,resolutionCandidate,peakQuarter,hasRisingAction:risingCandidate,hasClimax:climaxCandidate,hasResolution:resolutionCandidate,hasCliffhanger:null,hasSceneGoal:null}; + const wordsIn = s => (s.match(/\b[\w\u2019'-]+\b/g) || []).length; + const totalWords = wordsIn(text); + const legacy = { hasRisingAction: null, hasClimax: null, hasResolution: null, hasCliffhanger: null, hasSceneGoal: null }; + if (totalWords < 1200) return { score: null, applicable: false, arc: 'insufficient-data', details: 'Under 1,200 words: too short to measure structure.', units: [], unitCount: 0, components: {}, notAssessed: ['structure: under 1,200 words'], ...legacy }; + const { units, source } = this._structureUnits(text, mode); + const notAssessed = []; + // Composition per unit from the passage classifier (every unit sums the passages inside it). + const classifier = typeof ProseContext !== 'undefined'; + let passages = []; + if (classifier) { try { passages = ProseContext.classify(text); } catch (e) { passages = []; } } + for (const u of units) { + const tally = { dialogue: 0, action: 0, reflection: 0, description: 0, exposition: 0, mixed: 0 }; + for (const p of passages) { if (p.start >= u.start && p.start < u.end) tally[p.mode] = (tally[p.mode] || 0) + p.wordCount; } + const classified = tally.dialogue + tally.action + tally.reflection + tally.description + tally.exposition; + u.classifiedShare = u.words ? Math.round(classified / u.words * 100) : 0; + const pct = n => classified ? Math.round(n / classified * 100) : null; + u.sceneShare = classified ? Math.round((tally.action + tally.dialogue) / classified * 100) : null; + u.actionShare = pct(tally.action); u.dialogueShare = pct(tally.dialogue); u.reflectionShare = pct(tally.reflection); + u.descriptionShare = pct(tally.description); u.expositionShare = pct(tally.exposition); + const sents = text.slice(u.start, u.end).match(/[^.!?]+[.!?]+/g) || []; + u.meanSentence = sents.length ? Math.round(u.words / sents.length * 10) / 10 : null; + } + const n = units.length; + const measured = units.filter(u => u.sceneShare != null); + const components = {}; + // 1. Unit length holds to the book's own median (outliers under 0.3x or over 3x). + if (n >= 4) { + const sorted = units.map(u => u.words).sort((a, b) => a - b), median = sorted[n >> 1]; + const outliers = units.filter(u => u.words < median * 0.3 || u.words > median * 3); + components.lengthControl = { value: 1 - outliers.length / n, applicable: true, median, outliers: outliers.map(u => u.label + ' (' + u.words.toLocaleString() + ' words)'), basis: outliers.length + ' of ' + n + ' units outside 0.3x to 3x the median of ' + median.toLocaleString() + ' words' }; + } else notAssessed.push('unit length control: fewer than four units'); + // 2. Rhythm: scene share varies across the book (full credit at a 25-point range). + let curve = null; + if (!classifier) notAssessed.push('scene/summary rhythm and arc shape: passage classifier unavailable'); + else if (measured.length < 3) notAssessed.push('scene/summary rhythm and arc shape: fewer than three classified units'); + else { + const shares = measured.map(u => u.sceneShare); + const peak = Math.max(...shares), low = Math.min(...shares), peakIdx = shares.indexOf(peak); + const half = Math.floor(shares.length / 2); + const mean = a => a.reduce((x, y) => x + y, 0) / a.length; + curve = { peakUnit: measured[peakIdx].label, peakIndex: peakIdx + 1, peakPosition: Math.round(peakIdx / Math.max(shares.length - 1, 1) * 100) / 100, peakShare: peak, lowShare: low, amplitude: peak - low, firstHalfMean: Math.round(mean(shares.slice(0, half))), secondHalfMean: Math.round(mean(shares.slice(half))), lastUnitShare: shares[shares.length - 1], release: shares[shares.length - 1] <= peak - 5 }; + components.rhythm = { value: Math.min(1, curve.amplitude / 25), applicable: true, basis: 'scene share ranges ' + low + '% to ' + peak + '% across ' + measured.length + ' units (' + curve.amplitude + '-point range; full credit at 25)' }; + // 3. Arc shape, full manuscripts only: the most scene-heavy stretch sits in the back half + // and the final unit comes down from it. + if (mode === 'book' && measured.length >= 4 && curve.amplitude < 5) notAssessed.push('arc shape: scene share is flat (under a 5-point range), so there is no peak to place'); + else if (mode === 'book' && measured.length >= 4) { + const placement = curve.peakPosition >= 0.5 ? 1 : curve.peakPosition / 0.5; + components.shape = { value: 0.5 * placement + 0.5 * (curve.release ? 1 : 0), applicable: true, basis: 'most scene-heavy unit is ' + curve.peakUnit + ' (' + Math.round(curve.peakPosition * 100) + '% of the way through) \u00B7 final unit ' + (curve.release ? 'comes down ' + (peak - curve.lastUnitShare) + ' points from the peak' : 'is at the peak: no release measured') }; + } else notAssessed.push('arc shape: ' + (mode === 'book' ? 'fewer than four classified units' : 'not a full manuscript')); + } + // 4. Cast persistence: named characters recur across units; the lead is present in most. + const cast = characters && characters.list ? characters.list : this.analyzeCharacters(text, genre).list; + const named = (cast || []).slice(0, 40); + let castInfo = { qualified: named.length, recurring: 0, lead: null, leadPresence: null, names: [] }; + if (named.length >= 2 && n >= 2) { + const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + castInfo.names = named.map(c => { const re = new RegExp('\\b' + esc(c.name) + '\\b'); const inUnits = units.filter(u => re.test(text.slice(u.start, u.end))).length; return { name: c.name, units: inUnits, mentions: c.mentions }; }); + castInfo.recurring = castInfo.names.filter(c => c.units >= 2).length; + castInfo.lead = castInfo.names[0].name; castInfo.leadPresence = Math.round(castInfo.names[0].units / n * 100); + for (const u of units) u.cast = castInfo.names.filter(c => new RegExp('\\b' + esc(c.name) + '\\b').test(text.slice(u.start, u.end))).map(c => c.name).slice(0, 8); + components.cast = { value: 0.5 * (castInfo.recurring / named.length) + 0.5 * (castInfo.names[0].units / n), applicable: true, basis: castInfo.recurring + ' of ' + named.length + ' named characters recur across units \u00B7 ' + castInfo.lead + ' present in ' + castInfo.names[0].units + ' of ' + n + ' units' }; + } else notAssessed.push('cast persistence: fewer than two named characters'); + const vals = Object.values(components).filter(c => c.applicable).map(c => c.value); + const score = vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length * 100) : null; + return { score, applicable: score != null, arc: 'structure', mode: mode === 'book' ? 'book' : 'chapter', + methodology: 'Structure measured from the manuscript\'s units, the scene-versus-summary share of each unit (passage classifier), the placement of the most scene-heavy unit, unit-length control against the manuscript\'s own median, and cast persistence. Stakes, causality and character want are not measured.', + units: units.map(u => ({ label: u.label, words: u.words, sceneShare: u.sceneShare, actionShare: u.actionShare, dialogueShare: u.dialogueShare, reflectionShare: u.reflectionShare, descriptionShare: u.descriptionShare, expositionShare: u.expositionShare, classifiedShare: u.classifiedShare, meanSentence: u.meanSentence, cast: u.cast || [] })), + unitCount: n, unitSource: source, curve, cast: castInfo, components, notAssessed, ...legacy }; }, // ======================== @@ -1469,15 +1579,34 @@ const Analyzer = { // ======================== // TRANSITION ANALYSIS // ======================== + // A boundary is measured only between two prose paragraphs. A spoken line shares no + // content words with the line before it and needs no connective, so an exchange of + // dialogue is not a run of broken transitions; a chapter heading is not a paragraph at all. + // Those boundaries are counted and reported, never scored. + _paragraphKind(p){ + const t=p.trim(); + if(/^["“]/.test(t)||/["“][A-Z][^"”\n]{18,}[.,!?…][”"]/.test(t)) return 'dialogue'; + if(/^(chapter|part|book|prologue|epilogue|interlude)\b/i.test(t)&&t.split(/\s+/).length<=12) return 'heading'; + if(!/\n/.test(t)&&t.split(/\s+/).length<=8&&!/[.!?,;:]["”]?$/.test(t)) return 'heading'; + return 'prose'; + }, analyzeTransitions(text) { const paragraphs=text.split(/\n\s*\n/).filter(p=>p.trim()); - if(paragraphs.length<2) return {score:null,totalParagraphs:paragraphs.length,transitionsUsed:0,smoothTransitions:0,smoothRate:null,details:[],applicable:false}; + const kinds=paragraphs.map(p=>this._paragraphKind(p)); + const skipped={dialogue:0,heading:0}; + const notMeasured=(reason)=>({score:null,totalParagraphs:paragraphs.length,measuredBoundaries:0,skipped,transitionsUsed:0,smoothTransitions:0,smoothRate:null,details:[],applicable:false,reason,metric:'supported prose-to-prose paragraph boundaries'}); + if(paragraphs.length<2) return notMeasured('Fewer than two paragraphs.'); const stop=new Set('the a an and or but of to in on at for from with by as is are was were be been being it this that these those he she they we you i his her their our your my not no do did does have has had'.split(' ')); const contentWords=s=>(s.toLowerCase().match(/\b[a-z]{4,}\b/g)||[]).filter(w=>!stop.has(w)); const transitionRe=/^(however|moreover|furthermore|meanwhile|consequently|therefore|nevertheless|nonetheless|additionally|similarly|conversely|in contrast|on the other hand|as a result|in addition|for example|for instance|in other words|in fact|indeed|likewise|accordingly|thus|hence|still|yet|also|then|next|finally|afterwards|later|before|after|during|while|although|though|even though|because|since|when|once|until|unless)\b/i; let transitionsUsed=0,smoothTransitions=0; const details=[]; for(let i=1;iprevSet.has(w)))]; @@ -1493,10 +1622,11 @@ const Analyzer = { if(supported) smoothTransitions++; details.push({fromParagraph:i,toParagraph:i+1,marker,sharedTerms:shared.slice(0,8),overlapScore:Math.round(overlap*100),pronounBridge,supported}); } + if(details.length<3) return notMeasured('Fewer than three prose-to-prose paragraph boundaries ('+skipped.dialogue+' dialogue and '+skipped.heading+' heading boundaries were not measured).'); const smoothRate=Math.round(smoothTransitions/details.length*100); // Score is explicitly the supported-boundary rate. No arbitrary +20 floor or bonus // for using a preferred percentage of transition words. - return {score:smoothRate,totalParagraphs:paragraphs.length,transitionsUsed,smoothTransitions,smoothRate,details,applicable:true,metric:'supported paragraph boundaries'}; + return {score:smoothRate,totalParagraphs:paragraphs.length,measuredBoundaries:details.length,skipped,transitionsUsed,smoothTransitions,smoothRate,details,applicable:true,metric:'supported prose-to-prose paragraph boundaries'}; }, // ======================== @@ -1510,10 +1640,17 @@ const Analyzer = { const lengths=matches.map(d=>(d.match(/\b[\w’'-]+\b/g)||[]).length); const dialogueWords=lengths.reduce((a,b)=>a+b,0), ratio=dialogueWords/Math.max(totalWords,1)*100; if(isNF&&ratio<5)return {score:null,n_a:true,count:matches.length,ratio:+ratio.toFixed(1),applicable:false,findings:[],methodology:'Minimal nonfiction dialogue is descriptive only.'}; - const tagRe=/[”"]\s*(said|asked|whispered|shouted|muttered|replied|exclaimed|declared|murmured|yelled|cried|answered|stated|remarked|noted|suggested|demanded|insisted|pleaded|warned|admitted|announced|argued|claimed|complained|confirmed|denied|explained|protested|responded|snapped|stammered)\b/gi; + // A density over a handful of lines is noise: one "Fine." in three lines is 33 per 100. + if(matches.length<10)return {score:null,n_a:true,count:matches.length,ratio:+ratio.toFixed(1),applicable:false,findings:[],methodology:'Fewer than ten dialogue lines; too few to score a density.'}; + const speechVerbs='said|asked|whispered|shouted|muttered|replied|exclaimed|declared|murmured|yelled|cried|answered|stated|remarked|noted|suggested|demanded|insisted|pleaded|warned|admitted|announced|argued|claimed|complained|confirmed|denied|explained|protested|responded|snapped|stammered|added|sighed|laughed|called|repeated|growled|hissed|breathed|grumbled|urged|begged|inquired|echoed'; + const tagRe=new RegExp('[”"]\\s*('+speechVerbs+')\\b','gi'); const tags={}; for(const m of text.matchAll(tagRe)){const v=m[1].toLowerCase();tags[v]=(tags[v]||0)+1;} const totalTags=Object.values(tags).reduce((a,b)=>a+b,0),saidAsked=(tags.said||0)+(tags.asked||0); - const adverbTags=(text.match(/[”"]\s*\w+\s+\w+ly\b/gi)||[]).length; + // A tag adverb sits on a speech verb: "she said softly", "said Mary coldly". Any "-ly" + // word after any closing quote ("not only", "for only") is not one. + const notAdverb=/^(only|early|family|reply|supply|apply|holy|ugly|likely|lonely|friendly|lively|silly|jolly|belly|daily|elderly|kindly|lovely|deadly|costly|timely|italy|july|fly|ally|rally|bully|tally|folly|lily|jelly)$/i; + const tagAdverbRe=new RegExp('[”"]\\s*(?:\\w+\\s+){0,2}(?:'+speechVerbs+')\\s+(?:\\w+\\s+)?(\\w+ly)\\b|[”"]\\s*(?:\\w+\\s+){0,2}(\\w+ly)\\s+(?:'+speechVerbs+')\\b','gi'); + let adverbTags=0; for(const m of text.matchAll(tagAdverbRe)){const w=m[1]||m[2];if(w&&!notAdverb.test(w))adverbTags++;} const smallTalk=matches.filter(d=>/^[“"]\s*(hi|hello|hey|how are you|good morning|good evening|goodbye|bye|thanks|thank you|okay|ok|yeah|yes|no|fine|right|well|hmm|oh)\s*[.!?]?[”"]$/i.test(d)).length; const expositionMarkers=(text.match(/[“"][^”"]*\b(as you know|as we discussed|as i mentioned|let me explain|you need to understand)\b[^”"]*[”"]/gi)||[]).length; const longLines=lengths.filter(n=>n>60).length; @@ -1551,19 +1688,19 @@ const Analyzer = { for(let i=0;i=50)ttrs.push(new Set(w).size/w.length);} const lexicalDiversity=ttrs.length?mean(ttrs):(uniqueWords.size/Math.max(totalWords,1)); const avgWordLen=mean(words.map(w=>w.length)); - const firstPerson=(text.match(/\b(I|me|my|mine|myself)\b/g)||[]).length; + // Case-insensitive like the other two: "My" and "Me" at a sentence start are first person. + const firstPerson=(text.match(/\b(I|me|my|mine|myself)\b/gi)||[]).length; const secondPerson=(text.match(/\b(you|your|yours|yourself|yourselves)\b/gi)||[]).length; const thirdPerson=(text.match(/\b(he|she|they|him|her|them|his|hers|their|theirs)\b/gi)||[]).length; - const pov=firstPerson>Math.max(secondPerson,thirdPerson)*1.5?'First Person':secondPerson>Math.max(firstPerson,thirdPerson)*1.5?'Second Person':thirdPerson>Math.max(firstPerson,secondPerson)*1.5?'Third Person':'Mixed'; + // A first-person narrator still says "he" and "she" of everyone else, so first person + // wins whenever it leads; the 1.5x margin applies only between second and third. + const pov=firstPerson>0&&firstPerson>=Math.max(secondPerson,thirdPerson)*0.6?'First Person':secondPerson>Math.max(firstPerson,thirdPerson)*1.5?'Second Person':thirdPerson>Math.max(firstPerson,secondPerson)*1.5?'Third Person':'Mixed'; const sentenceStdDev=sd(sentLens),paragraphStdDev=sd(paraLens); - // Style score measures observable control/variety only. POV choice and average word - // length are descriptive traits, not quality points. - let score=100; - if(sentLens.length>=5 && sentenceStdDev<3) score-=20; - if(paraLens.length>=5 && paragraphStdDev<8) score-=15; - if(lexicalDiversity<0.45) score-=15; - if(lexicalDiversity<0.35) score-=15; - return {score:Math.max(0,Math.min(100,Math.round(score))),totalWords,uniqueWords:uniqueWords.size,lexicalDiversity:Math.round(lexicalDiversity*100),lexicalMetric:'MSTTR-100',avgWordLength:Math.round(avgWordLen*10)/10,avgSentenceLength:Math.round(mean(sentLens)*10)/10,sentenceLengthStdDev:Math.round(sentenceStdDev*10)/10,avgParagraphLength:Math.round(mean(paraLens)),paragraphLengthStdDev:Math.round(paragraphStdDev*10)/10,pov,pronouns:{first:firstPerson,second:secondPerson,third:thirdPerson},paragraphCount:paragraphs.length,sentenceCount:sentences.length}; + // Style is described, not scored. The old score was 100 minus fixed penalties at + // thresholds real prose never crosses (MSTTR under 45%, sentence SD under 3), so every + // manuscript scored 100 and the dimension only inflated the overall. The measurements + // stay; a score returns when there is one that discriminates. + return {score:null,scored:false,totalWords,uniqueWords:uniqueWords.size,lexicalDiversity:Math.round(lexicalDiversity*100),lexicalMetric:'MSTTR-100',avgWordLength:Math.round(avgWordLen*10)/10,avgSentenceLength:Math.round(mean(sentLens)*10)/10,sentenceLengthStdDev:Math.round(sentenceStdDev*10)/10,avgParagraphLength:Math.round(mean(paraLens)),paragraphLengthStdDev:Math.round(paragraphStdDev*10)/10,pov,pronouns:{first:firstPerson,second:secondPerson,third:thirdPerson},paragraphCount:paragraphs.length,sentenceCount:sentences.length}; }, // ======================== @@ -1881,7 +2018,8 @@ const Analyzer = { const doubleRe = /\b(\w{2,})\s+\1\b/gi; let dm; while ((dm = doubleRe.exec(text)) !== null) { - if (/^(had|that|is|do|was|in|so|no)$/i.test(dm[1])) continue; + // "had had", "that that", and a double object ("gave her her own way") are grammar. + if (/^(had|that|is|do|was|in|so|no|her|him|them|us|me|you)$/i.test(dm[1])) continue; issues.push({ type: 'grammar', text: dm[0], index: dm.index, length: dm[0].length, severity: 'high', confidence: 0.95, @@ -1907,6 +2045,8 @@ const Analyzer = { while ((m = r.exec(text)) !== null) { const inDialogue = this._isInsideQuotes(text, m.index); if (inDialogue) continue; + // "as if she were", "I wish it were", "if he were": the subjunctive is correct. + if (/^were$/i.test(m[2]) && /\b(as if|as though|if|even if|wish|wished|wishes|wishing|suppose|supposing|though|unless|whether|lest)\s*$/i.test(text.slice(Math.max(0, m.index - 24), m.index))) continue; const fixed = p.fix.replace('$1', m[1]).replace('$2', m[2]); issues.push({ type: 'grammar', text: m[0], index: m.index, length: m[0].length, @@ -1921,6 +2061,9 @@ const Analyzer = { const capRe = /([.!?])\s+([a-z])/g; let cm; while ((cm = capRe.exec(text)) !== null) { + // Inside speech, "Eh! you mustn't" and "My word! she's" are the speaker's cadence, + // not a capitalisation error. The agreement rules skip quoted text; so does this one. + if (this._isInsideQuotes(text, cm.index)) continue; const before = text.substring(Math.max(0, cm.index - 15), cm.index + 1); if (/\.\.\.$/.test(before)) continue; // Single uppercase letter + period (U.S., A.M., D.C.) @@ -1941,7 +2084,7 @@ const Analyzer = { // --- 4. DIALOGUE PUNCTUATION --- // Missing comma before dialogue tag: "Hello" he said - const dtagRe = /([.!?]?)("|")\s+(he|she|they|I|we|it|[A-Z][a-z]+)\s+(said|asked|whispered|shouted|yelled|muttered|replied|murmured|growled|hissed|snapped|stammered|called|cried|exclaimed|answered|demanded|pleaded|begged|insisted|warned|suggested|offered|added|continued|began|started|interrupted|responded|acknowledged|admitted|agreed|announced|argued|barked|bellowed|blurted|boasted|breathed|chanted|chided|chimed|choked|clucked|coaxed|commanded|commented|complained|conceded|concluded|confessed|confided|confirmed|croaked|crooned|cursed|declared|denied|drawled|echoed|elaborated|emphasized|encouraged|estimated|explained|faltered|gasped|giggled|gloated|grumbled|grunted|guessed|gulped|huffed|hummed|implored|informed|interjected|joked|lamented|laughed|lectured|lied|lisped|maintained|marveled|mentioned|mimicked|moaned|mocked|mumbled|mused|nagged|narrated|noted|objected|observed|ordered|panted|parroted|persisted|persuaded|piped|pondered|pouted|praised|prayed|pressed|proclaimed|promised|prompted|pronounced|proposed|protested|provoked|purred|quavered|quipped|quoted|ranted|reasoned|recalled|reckoned|recounted|reflected|refused|reminded|repeated|reported|requested|resumed|retorted|revealed|roared|sang|scoffed|scolded|screamed|sighed|slurred|smiled|smirked|sneered|snickered|sniffed|snorted|sobbed|speculated|spluttered|squeaked|squealed|stammered|stated|stuttered|surmised|taunted|teased|threatened|thundered|urged|uttered|ventured|vowed|wailed|warned|wept|whimpered|whined|whispered|wondered|worried|yawned)\b/g; + const dtagRe = /([.!?,]?)(["\u201D])\s+(he|she|they|I|we|it|[A-Z][a-z]+)\s+(said|asked|whispered|shouted|yelled|muttered|replied|murmured|growled|hissed|snapped|stammered|called|cried|exclaimed|answered|demanded|pleaded|begged|insisted|warned|suggested|offered|added|continued|began|started|interrupted|responded|acknowledged|admitted|agreed|announced|argued|barked|bellowed|blurted|boasted|breathed|chanted|chided|chimed|choked|clucked|coaxed|commanded|commented|complained|conceded|concluded|confessed|confided|confirmed|croaked|crooned|cursed|declared|denied|drawled|echoed|elaborated|emphasized|encouraged|estimated|explained|faltered|gasped|giggled|gloated|grumbled|grunted|guessed|gulped|huffed|hummed|implored|informed|interjected|joked|lamented|laughed|lectured|lied|lisped|maintained|marveled|mentioned|mimicked|moaned|mocked|mumbled|mused|nagged|narrated|noted|objected|observed|ordered|panted|parroted|persisted|persuaded|piped|pondered|pouted|praised|prayed|pressed|proclaimed|promised|prompted|pronounced|proposed|protested|provoked|purred|quavered|quipped|quoted|ranted|reasoned|recalled|reckoned|recounted|reflected|refused|reminded|repeated|reported|requested|resumed|retorted|revealed|roared|sang|scoffed|scolded|screamed|sighed|slurred|smiled|smirked|sneered|snickered|sniffed|snorted|sobbed|speculated|spluttered|squeaked|squealed|stammered|stated|stuttered|surmised|taunted|teased|threatened|thundered|urged|uttered|ventured|vowed|wailed|warned|wept|whimpered|whined|whispered|wondered|worried|yawned)\b/g; let dtm; while ((dtm = dtagRe.exec(text)) !== null) { if (dtm[1]) continue; @@ -2249,10 +2392,10 @@ const Analyzer = { if(!supported&&flowEvidence.length<20) flowEvidence.push({fromSentence:i,toSentence:i+1,excerpt:sentences[i].slice(0,140)}); } const unsupportedRate=unsupported/Math.max(sentences.length-1,1)*100; - // Word overlap between sentences is the wrong test for instructional and rhetorical prose - // (short punches, deliberate turns, anaphora): nonfiction is not scored on it. - let flowScore=isNF?null:clamp(100-Math.min(45,unsupportedRate*1.2)); - if(!isNF&&unsupportedRate>25) findings.push({type:'flow',severity:'medium',count:unsupported,rate:+unsupportedRate.toFixed(1),message:unsupported+' sentence boundaries ('+unsupportedRate.toFixed(1)+'%) lack an explicit connector or repeated content term. Inspect these boundaries before revising.',evidence:flowEvidence}); + // Word overlap between adjacent sentences is not a test of flow in any register: a + // published novel runs near 80% "unsupported" and floored at the maximum penalty. The + // counts stay as description; nothing is scored on them. + const flowScore=null; // Precision: normalize all penalties by manuscript length. Common words are candidates, // not automatically "bad"; the score responds only to unusually high density. @@ -2290,9 +2433,10 @@ const Analyzer = { const shares={first:first/totalPronouns,second:second/totalPronouns,third:third/totalPronouns}; const dominant=Object.entries(shares).sort((a,b)=>b[1]-a[1])[0][0]; const sorted=Object.values(shares).sort((a,b)=>b-a); + // A first-person narrator who describes other people uses "he" and "she" constantly; + // a pronoun mix cannot tell that from a slip. The shares are reported, not scored. const mixedNarration=!isNF&&totalPronouns>=30&&sorted[0]<0.7&&sorted[1]>0.2; - const povScore=isNF?null:clamp(100-(mixedNarration?20:0)); - if(mixedNarration) findings.push({type:'pov',severity:'low',message:'Narration pronouns are materially mixed (first '+Math.round(shares.first*100)+'%, second '+Math.round(shares.second*100)+'%, third '+Math.round(shares.third*100)+'%). This is a review candidate, not proof of a POV error.'}); + const povScore=null; // Extraneous language: length-normalized candidate density. "That" and dialogue tags // are not automatically errors, so they remain telemetry instead of fixed penalties. @@ -2304,17 +2448,15 @@ const Analyzer = { if(filterRate>3) findings.push({type:'extraneous',severity:'low',count:filterMatches.length,ratePerK:+filterRate.toFixed(1),message:filterMatches.length+' filter-phrase candidates ('+filterRate.toFixed(1)+' per 1K words). Review for places where the direct verb is stronger.'}); const dims=[toneScore,precisionScore,pacingScore,extraneousScore]; - if(flowScore!==null)dims.push(flowScore); - if(povScore!==null)dims.push(povScore); const score=clamp(mean(dims)); return { score,applicable:true, - methodology:'Evidence-normalized line-editing model; counts are normalized by manuscript length and POV is advisory.', + methodology:'Evidence-normalized line-editing model over tone, precision, rhythm and extraneous language; counts are normalized by manuscript length. Sentence continuity and POV are described, not scored.', tone:{score:toneScore,formalWords:formal,casualWords:casual,registerMix,registerMixRate:+registerMixRate.toFixed(2)}, - flow:{score:flowScore,advisory:isNF,unsupportedBoundaries:unsupported,unsupportedRate:+unsupportedRate.toFixed(1),evidence:flowEvidence}, + flow:{score:flowScore,advisory:true,unsupportedBoundaries:unsupported,unsupportedRate:+unsupportedRate.toFixed(1),evidence:flowEvidence}, precision:{score:precisionScore,vagueWords:vagueMatches.length,vagueRatePerK:+vagueRate.toFixed(1),redundantMods:redundant.length,redundantRatePerK:+redundantRate.toFixed(1)}, pacing:{score:pacingScore,avgSentenceLength:+avgLen.toFixed(1),stdDev:+stdDev.toFixed(1),longDialogueParagraphs:longDialogue.length,longDialogueRate:+longDialogueRate.toFixed(1)}, - pov:{score:povScore,advisory:isNF,dominant,firstPerson:first,secondPerson:second,thirdPerson:third,shares:{first:Math.round(shares.first*100),second:Math.round(shares.second*100),third:Math.round(shares.third*100)},mixedCandidate:mixedNarration}, + pov:{score:povScore,advisory:true,dominant,firstPerson:first,secondPerson:second,thirdPerson:third,shares:{first:Math.round(shares.first*100),second:Math.round(shares.second*100),third:Math.round(shares.third*100)},mixedCandidate:mixedNarration}, extraneous:{score:extraneousScore,filterPhrases:filterMatches.length,filterRatePerK:+filterRate.toFixed(1),thatCount,thatRatePerK:+thatRate.toFixed(1)}, findings }; @@ -3445,7 +3587,9 @@ const Analyzer = { // COPY EDITING SCORE // ======================== scoreCopyEditing(issues, totalWords) { - const copyTypes=new Set(['passive','adverb','cliche','wordy','confused-word','repetition','grammar']); + // Grammar has its own dimension and is not counted here a second time; this set is + // the same one the workbench lists under Copy Editing. + const copyTypes=new Set(['passive','adverb','cliche','wordy','confused-word','repetition']); const copyIssues=issues.filter(i=>copyTypes.has(i.type)); const perK=copyIssues.length/Math.max(totalWords,1)*1000; // A transparent density index: 0 validated findings = 100. The curve is monotonic @@ -3572,7 +3716,8 @@ const Analyzer = { const narrativeWords = segmentation.narrativeWords; // STAGE 2: literary analysis — every narrative dimension reads narrative text only. - const plot = this.analyzePlot(aText, mode, genre); + const characters = this.analyzeCharacters(aText, genre); + const plot = this.analyzePlot(aText, mode, genre, characters); const transitions = this.analyzeTransitions(aText); const dialogue = this.analyzeDialogue(aText, genre); const style = this.analyzeStyle(aText); @@ -3583,7 +3728,6 @@ const Analyzer = { // Backfill readerPerspective.dnfRisk from new engine for backward compat readerPerspective.dnfRisk = dnfAnalysis.dnf_risk; const pacing = this.analyzePacing(aText); - const characters = this.analyzeCharacters(aText, genre); const blurbs = this.generateBlurbs(aText, characters, genre, mode); const scifiWorld = this.analyzeSciFiWorldbuilding(aText, genre); const genreElements = this.analyzeGenreElements(aText, genre); diff --git a/app.html b/app.html index 7ccc0b8..524c27e 100644 --- a/app.html +++ b/app.html @@ -398,11 +398,11 @@

Choos - + - + '; } h+=''; h+='
'; // filled by renderLeft: the one "Start here" card @@ -923,18 +923,19 @@ 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||{}; return [ - {k:'plot',name:isNF?'Argument Structure':'Plot Structure',score:scores.plot||0,density:null,issues:0,weight:'9%',evidence:plotEv}, - {k:'transitions',name:'Transitions',score:scores.transitions||0,density:null,issues:(t.details||[]).filter(x=>!x.supported).length,weight:'7%',evidence:t.smoothRate!=null?t.smoothRate+'% of paragraph boundaries supported':''}, + {k:'plot',name:isNF?'Argument Structure':'Plot Structure',score:scores.plot??null,density:null,issues:0,weight:'9%',evidence:scores.plot==null?'not measured: '+(p.details||'too little text'):plotEv}, + {k:'transitions',name:'Transitions',score:scores.transitions??null,density:null,issues:(t.details||[]).filter(x=>!x.supported).length,weight:'7%',evidence:t.smoothRate!=null?t.smoothRate+'% of '+(t.measuredBoundaries??'')+' prose-to-prose boundaries supported'+((t.skipped?.dialogue||0)?' · '+t.skipped.dialogue+' dialogue boundaries not judged':''):'not measured: '+(t.reason||'too few prose paragraphs')}, {k:'hook',name:'Hook Strength',score:hookScore,density:null,issues:hookScore<80?hookProblems:0,weight:'9%',evidence:hookProblems?hookProblems+' opening problem'+(hookProblems===1?'':'s')+' diagnosed':'no opening problems diagnosed'}, - {k:'style',name:'Style & Voice',score:scores.style||0,density:null,issues:0,weight:'7%',evidence:st.lexicalDiversity!=null?'lexical diversity '+st.lexicalDiversity+'% \u00B7 sentence SD '+(st.sentenceLengthStdDev??'\u2014'):''}, + // Style is described, not scored: its measurements never separated one manuscript from another. + {k:'style',name:'Style & Voice',score:null,density:null,issues:0,weight:'not scored',evidence:st.lexicalDiversity!=null?(st.pov||'')+' \u00B7 lexical diversity '+st.lexicalDiversity+'% \u00B7 sentence SD '+(st.sentenceLengthStdDev??'\u2014'):''}, ...(scores.dialogue!=null?[{k:'dialogue',name:'Dialogue',score:scores.dialogue,density:null,issues:countType('dialogue'),weight:'6%',evidence:dl.candidatesPer100!=null?dl.candidatesPer100+' candidates per 100 lines \u00B7 '+(dl.count||0)+' lines':''}]:[]), ...(!isNF?[{k:'showTell',name:'Show vs Tell',score:scores.showTell||0,density:perPage(stIssues),issues:stIssues,weight:'7%',evidence:''}]:[]), {k:'copy',name:'Copy Editing',score:scores.copy||0,density:perPage(copyRaw),issues:copyRaw,weight:'10%',evidence:''}, @@ -994,11 +995,12 @@ function renderRight(r){ // A row per dimension, a third of the height of the old ring cards, so the findings for the // selected dimension sit above the fold. container.innerHTML=cats.map(c=>{ - const col=scHex(c.score);const basis=_dimensionBasis(c); - return '
' + const scored=Number.isFinite(c.score); + const col=scored?scHex(c.score):'var(--muted)';const basis=_dimensionBasis(c); + return '
' +''+esc(c.name)+'' +''+esc(basis)+'' - +''+c.score+'
'; + +''+(scored?c.score:'\u2014')+'
'; }).join(''); // Click handlers container.querySelectorAll('.rsc').forEach(el=>{el.addEventListener('click',()=>{container.querySelectorAll('.rsc').forEach(e=>e.classList.remove('active'));el.classList.add('active');_activeDetailCat=el.dataset.cat;showDetail(el.dataset.cat)})}); @@ -1220,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:'Generic verbs ("made", "went", "got") miss an opportunity to create vivid, specific imagery.',showTell:'Telling emotions ("she felt sad") keeps readers at arm\'s length. Showing through action and sensory detail creates empathy.',copy:'Copy editing catches the mechanical issues — passive voice, adverbs, clichés, wordy phrasing, and word confusion.',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']); @@ -1368,31 +1370,36 @@ 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||0,plotRows,'plot',r); - h+=secWithTip('Transitions',scores.transitions||0,[(trans.smoothRate||0)+'% smooth',sr('Transition Words',trans.transitionsUsed||0),sr('Smooth',(trans.smoothTransitions||0)+'/'+((trans.totalParagraphs||1)-1))],'transitions',r); + h+=secWithTip(plotLabel,scores.plot??null,plotRows,'plot',r); + h+=secWithTip('Transitions',scores.transitions??null,[trans.smoothRate!=null?trans.smoothRate+'% of prose-to-prose paragraph boundaries supported by a connective, shared terms or a referential bridge':(trans.reason||'Not measured.'),sr('Boundaries measured',trans.measuredBoundaries??0),sr('Supported',(trans.smoothTransitions||0)+'/'+(trans.measuredBoundaries??0)),sr('Opening connectives',trans.transitionsUsed||0),sr('Dialogue boundaries not judged',trans.skipped?.dialogue??0),sr('Heading boundaries not judged',trans.skipped?.heading??0)],'transitions',r); h+=secWithTip('Copy Editing',scores.copy||0,[((ic.passive||0)+(ic.adverb||0)+(ic.cliche||0)+(ic.wordy||0)+(ic['confused-word']||0)+(ic.repetition||0))+' copy issues in '+(r.totalWords||0).toLocaleString()+' words',sr('Passive',ic.passive||0),sr('Adverbs',ic.adverb||0),sr('Cliches',ic.cliche||0),sr('Wordy',ic.wordy||0),sr('Repetition',ic.repetition||0),sr('Confused Words',ic['confused-word']||0)],'copy',r); // Line Editing (true stylistic editing, not just readability) const le=r.lineEditing||{}; - const lineRows=['Stylistic editing: tone, flow, precision, pacing, POV, extraneous language']; + const lineRows=['Scored: tone, word precision, sentence rhythm, extraneous language. Described only: sentence continuity and POV.']; lineRows.push(sr('Tone / Register Evidence',(le.tone?.score??'N/A')+(le.tone?.score==null?'':'/100'))); - lineRows.push(sr('Sentence Continuity',le.flow?.score==null?(le.flow?.advisory?'N/A — not judged for nonfiction':'N/A'):le.flow.score+'/100')); lineRows.push(sr('Word Precision',(le.precision?.score??'N/A')+(le.precision?.score==null?'':'/100'))); lineRows.push(sr('Sentence Rhythm',(le.pacing?.score??'N/A')+(le.pacing?.score==null?'':'/100'))); - lineRows.push(sr('POV Review',le.pov?.score==null?'Advisory only':le.pov.score+'/100')); lineRows.push(sr('Extraneous Language',(le.extraneous?.score??'N/A')+(le.extraneous?.score==null?'':'/100'))); - if(le.flow?.unsupportedBoundaries!=null)lineRows.push(sr('Unsupported Sentence Boundaries',le.flow.unsupportedBoundaries+' ('+le.flow.unsupportedRate+'%)')); + if(le.pov?.shares)lineRows.push(sr('Narration pronouns (not scored)','first '+le.pov.shares.first+'% · second '+le.pov.shares.second+'% · third '+le.pov.shares.third+'%')); + if(le.flow?.unsupportedBoundaries!=null)lineRows.push(sr('Sentence boundaries without a shared term (not scored)',le.flow.unsupportedBoundaries+' ('+le.flow.unsupportedRate+'%)')); if(le.precision?.vagueRatePerK!=null)lineRows.push(sr('Precision Candidates / 1K',le.precision.vagueRatePerK)); if(le.extraneous?.filterRatePerK!=null)lineRows.push(sr('Filter Phrases / 1K',le.extraneous.filterRatePerK)); // Show findings @@ -1407,7 +1414,7 @@ function renderDetailed(r){ lineRows.push(sr('Readability Grade',r.readability?.grade||'N/A')); lineRows.push(sr('Flesch Ease',(r.readability?.ease||0)+'/100')); h+=secWithTip('Line Editing',r.scores?.line||0,lineRows,'line',r); - h+=secWithTip('Style & Voice',r.scores?.style||0,[sr('POV',r.style?.pov||'N/A'),sr('Lexical Diversity (MSTTR-100)',(r.style?.lexicalDiversity||0)+'%'),sr('Sentence Length SD',r.style?.sentenceLengthStdDev??'N/A'),sr('Paragraph Length SD',r.style?.paragraphLengthStdDev??'N/A'),sr('Unique Words',(r.style?.uniqueWords||0).toLocaleString())],'style',r); + h+=secWithTip('Style & Voice',null,['Described, not scored: these measurements do not separate one manuscript from another.',sr('POV',r.style?.pov||'N/A'),sr('Lexical Diversity (MSTTR-100)',(r.style?.lexicalDiversity||0)+'%'),sr('Sentence Length SD',r.style?.sentenceLengthStdDev??'N/A'),sr('Paragraph Length SD',r.style?.paragraphLengthStdDev??'N/A'),sr('Unique Words',(r.style?.uniqueWords||0).toLocaleString())],'style',r); // Dialogue (deep analysis) const dl=r.dialogue||{count:0,ratio:0}; const dlRows=[dl.count===0?'No dialogue detected.':'']; @@ -1488,7 +1495,9 @@ function renderDetailed(r){ } d.innerHTML=h; } -function sec(t,s,items){return '

'+t+' '+s+'/100

'+items.filter(Boolean).map(i=>typeof i==='string'?(i?'

'+i+'

':''):i).join('')+'
'} +// A null score renders as "not scored", never as 0. +function scoreBadge(s){return Number.isFinite(s)?''+s+'/100':'not scored'} +function sec(t,s,items){return '

'+t+' '+scoreBadge(s)+'

'+items.filter(Boolean).map(i=>typeof i==='string'?(i?'

'+i+'

':''):i).join('')+'
'} function sr(l,v){return '
'+l+''+v+'
'} // Improvement suggestions for low-scoring areas @@ -1523,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||[]); @@ -1541,11 +1553,11 @@ function evidenceTip(r,key,score){ } function secWithTip(t,s,items,key,r){ let tip=''; - if(s<70&&key&&r){ + if(Number.isFinite(s)&&s<70&&key&&r){ const msg=evidenceTip(r,key,s); if(msg)tip='
Evidence-Based Next Step
'+esc(msg)+'
'; } - return '

'+t+' '+s+'/100

'+items.filter(Boolean).map(i=>typeof i==='string'?(i?'

'+i+'

':''):i).join('')+tip+'
'; + return '

'+t+' '+scoreBadge(s)+'

'+items.filter(Boolean).map(i=>typeof i==='string'?(i?'

'+i+'

':''):i).join('')+tip+'
'; } // READER VIEW @@ -2346,7 +2358,7 @@ document.querySelectorAll('.rtab').forEach(t=>{t.addEventListener('click',()=>{ const isNFtone=Analyzer.isNonfiction(r&&r.genre); const toneFindings=(r.lineEditing?.findings||[]).filter(f=>f.type==='tone'||f.type==='flow'); const flowLabel=isNFtone?'Sentence Rhythm':'Flow'; - const scoreLine=(r.lineEditing?.tone?.score!==undefined)?'
Tone score: '+r.lineEditing.tone.score+'/100 · '+flowLabel+': '+r.lineEditing.flow.score+'/100
':''; + const scoreLine=(r.lineEditing?.tone?.score!==undefined)?'
Tone score: '+r.lineEditing.tone.score+'/100 · '+flowLabel+': '+(Number.isFinite(r.lineEditing.flow?.score)?r.lineEditing.flow.score+'/100':'described, not scored')+'
':''; let html='
Tone & Flow
'+scoreLine; if(toneFindings.length===0){ html+='

No tonal inconsistencies detected. Register and mood stay consistent.

'; diff --git a/audit-fixes.test.js b/audit-fixes.test.js new file mode 100644 index 0000000..b876d96 --- /dev/null +++ b/audit-fixes.test.js @@ -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); +}); 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/demo-firebase.js b/demo-firebase.js index 792df71..fe38395 100644 --- a/demo-firebase.js +++ b/demo-firebase.js @@ -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}; diff --git a/detailed-data-integrity.test.js b/detailed-data-integrity.test.js index b1d06b6..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',()=>{ @@ -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)); diff --git a/detailed-metrics.test.js b/detailed-metrics.test.js index 2e5d4c8..59e6915 100644 --- a/detailed-metrics.test.js +++ b/detailed-metrics.test.js @@ -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',()=>{ diff --git a/index.html b/index.html index 87eeb9a..407c445 100644 --- a/index.html +++ b/index.html @@ -10,9 +10,9 @@ - + - + @@ -27,7 +27,7 @@
- +
diff --git a/init.js b/init.js index 0df2263..d980297 100644 --- a/init.js +++ b/init.js @@ -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'); @@ -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); diff --git a/landing-init.js b/landing-init.js index 54d103e..a6fff78 100644 --- a/landing-init.js +++ b/landing-init.js @@ -3,8 +3,13 @@ 'use strict'; const $ = id => document.getElementById(id); const dialog = $('auth-dialog'); - let mode = 'signin', busy = false, authPromise = null, currentUser = null; + let mode = 'signin', busy = false, authPromise = null, currentUser = null, firstState = null; let returnFocus = null; + // The workspace sets this hint on sign-in and clears it on sign-out. It only decides whether the + // homepage loads the auth SDK before anyone clicks; Firebase itself says whether a session exists. + const SESSION_HINT = 'ml_signed_in'; + function hasSessionHint() { try { return localStorage.getItem(SESSION_HINT) === '1'; } catch (_) { return false; } } + function setSessionHint(on) { try { on ? localStorage.setItem(SESSION_HINT, '1') : localStorage.removeItem(SESSION_HINT); } catch (_) { /* storage blocked */ } } const root = document.documentElement; function setTheme(theme) { root.dataset.theme = theme; @@ -37,23 +42,38 @@ if (typeof FIREBASE_CONFIG === 'undefined') await loadScript('firebase-config.js'); if (!firebase.apps.length) firebase.initializeApp(FIREBASE_CONFIG); const auth = firebase.auth(); - auth.onAuthStateChanged(user => { + firstState = new Promise(resolve => auth.onAuthStateChanged(user => { currentUser = user; + setSessionHint(!!user); $('nl').classList.toggle('hidden', !!user); $('nu').classList.toggle('hidden', !user); - }); + resolve(user); + })); return auth; - })().catch(error => { authPromise = null; throw error; }); + })().catch(error => { authPromise = null; $('nl').classList.remove('hidden'); throw error; }); return authPromise; } + // Who is here? Null for a visitor with no session hint (no SDK load, no wait); otherwise the + // restored user once Firebase has reported, or null if it cannot within a few seconds. + function knownUser() { + if (currentUser) return Promise.resolve(currentUser); + if (!hasSessionHint()) return Promise.resolve(null); + return Promise.race([ + getAuth().then(() => firstState), + new Promise(resolve => setTimeout(() => resolve(null), 5000)) + ]).catch(() => null); + } + // A returning author sees "Open workspace", not "Sign in": load auth now instead of on click, and + // keep the signed-out buttons out of sight until Firebase has answered. + if (hasSessionHint()) { $('nl').classList.add('hidden'); knownUser().then(user => { if (!user) $('nl').classList.remove('hidden'); }); } function message(text) { $('auth-message').textContent = text; } function setBusy(value) { busy = value; ['auth-submit','google-signin','auth-toggle','forgot-password'].forEach(id => $(id).disabled = value); $('auth-submit').textContent = value ? 'Please wait…' : mode === 'signup' ? 'Create account' : mode === 'reset' ? 'Send reset email' : 'Sign in'; } - function showAuth(nextMode = 'signin') { - if (currentUser && nextMode !== 'reset') { location.href = 'app.html'; return; } + async function showAuth(nextMode = 'signin') { + if (nextMode !== 'reset' && await knownUser()) { location.href = 'app.html'; return; } mode = nextMode; $('auth-title').textContent = mode === 'signup' ? 'Begin your story.' : mode === 'reset' ? 'A fresh start.' : 'Welcome back.'; $('auth-subtitle').textContent = mode === 'signup' ? 'Create your free writing workspace.' : mode === 'reset' ? 'We’ll email you a password reset link.' : 'Sign in to return to your manuscripts.'; @@ -70,6 +90,21 @@ getAuth().catch(error => message(error.message)); } $('nav-signin').addEventListener('click', () => showAuth()); + // Sign out from the homepage: end the Firebase session and drop the hint; onAuthStateChanged + // then restores the signed-out nav. Drafts in local storage are left alone here (the workspace + // owns their recovery flow); only this tab's session cache is cleared. + $('nav-signout').addEventListener('click', async () => { + const button = $('nav-signout'); + if (button.disabled) return; + button.disabled = true; + try { + const auth = await getAuth(); + await auth.signOut(); + setSessionHint(false); + try { sessionStorage.clear(); } catch (_) { /* storage blocked */ } + } catch (_) { alert('Sign-out failed. Please try again.'); } + finally { button.disabled = false; } + }); ['nav-signup','hero-cta','bottom-cta'].forEach(id => $(id).addEventListener('click', () => showAuth('signup'))); // The workspace demo's "Create a free account" link lands here. if (location.hash === '#signup') { history.replaceState(null, '', location.pathname); showAuth('signup'); } @@ -106,6 +141,7 @@ const name = $('auth-name').value.trim(); if (name) await credential.user.updateProfile({ displayName: name }); } else await auth.signInWithEmailAndPassword(email, password); + setSessionHint(true); location.href = 'app.html'; } } catch (error) { message(errors[error.code] || 'Unable to complete sign-in. Please try again.'); } @@ -117,6 +153,7 @@ try { const auth = await getAuth(); await auth.signInWithPopup(new firebase.auth.GoogleAuthProvider()); + setSessionHint(true); location.href = 'app.html'; } catch (error) { if (error.code !== 'auth/popup-closed-by-user') message(errors[error.code] || 'Google sign-in did not complete. Try again or use email.'); diff --git a/landing.css b/landing.css index 3af34f7..523607e 100644 --- a/landing.css +++ b/landing.css @@ -1,13 +1,13 @@ :root{color-scheme:dark;--bg:#141710;--surface:#1c2018;--text:#f1e7d4;--muted:#bcb6a7;--line:#b9a07835;--gold:#ca9a60;--button:#805021;--paper-ink:#25291d;--display:'Cormorant Garamond',Georgia,serif;--body:'Inter',sans-serif;--space-1:.25rem;--space-2:.5rem;--space-3:.75rem;--space-4:1rem;--space-6:1.5rem;--space-8:2rem;--space-12:3rem;--space-16:4rem;--text-xs:.75rem;--text-sm:.875rem;--text-base:1rem;--text-lg:1.125rem;--text-xl:clamp(1.5rem,2vw,2rem);--text-heading:clamp(2.5rem,4.2vw,4rem);--text-hero:clamp(2.8rem,5.5vw,5.3rem)} *{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:var(--space-8)}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--body);font-size:var(--text-base);line-height:1.65}button,input{font:inherit}button,a{-webkit-tap-highlight-color:transparent}button,a.button{cursor:pointer}a{color:inherit;text-decoration:none}button{color:inherit}button:disabled{opacity:.55;cursor:wait}.hidden{display:none!important}h1,h2,h3,p{margin:0}em{font-weight:500}a,button,input{outline-offset:5px}a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--gold)}.skip-link{position:fixed;top:-80px;left:16px;background:var(--bg);padding:var(--space-4);z-index:100}.skip-link:focus{top:8px} -.site-header{max-width:1536px;margin:auto;min-height:100px;padding:var(--space-4) clamp(1.25rem,5vw,5rem);display:flex;align-items:center;justify-content:space-between;gap:var(--space-6)}.brand{display:flex;align-items:center;gap:var(--space-3);font:600 2rem/1 var(--display);white-space:nowrap}.brand svg{width:35px;height:45px;color:#a3ab73}.brand-accent{color:var(--gold)}.brand small{display:block;margin-top:var(--space-2);font:500 .625rem/1.2 var(--body);letter-spacing:.3em;color:var(--text)}.desktop-nav{display:flex;gap:clamp(1rem,2.3vw,2rem);font-size:var(--text-sm)}.desktop-nav a,.text-button{color:var(--text)}.desktop-nav a:hover,.site-footer a:hover{color:var(--gold)}.header-actions,#nl{display:flex;align-items:center;gap:var(--space-4)}.text-button,.theme-toggle{background:none;border:0;min-height:44px;font-size:var(--text-sm);padding:var(--space-2)}.theme-toggle{font-size:1.4rem;min-width:44px;color:var(--gold)}.button{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-6);min-height:48px;background:var(--button);color:#fff7e9;border:1px solid transparent;border-radius:5px;padding:var(--space-3) var(--space-6);font-weight:500;font-size:var(--text-sm);line-height:1.5;transition:background .2s,transform .2s}.button:hover{background:#653c17;transform:translateY(-1px)}.button.compact{min-height:44px;padding:var(--space-2) var(--space-6);background:#a86b33}.menu-toggle{display:none}.mobile-nav{padding:var(--space-4);border-block:1px solid var(--line)} +.site-header{max-width:1536px;margin:auto;min-height:100px;padding:var(--space-4) clamp(1.25rem,5vw,5rem);display:flex;align-items:center;justify-content:space-between;gap:var(--space-6)}.brand{display:flex;align-items:center;gap:var(--space-3);font:600 2rem/1 var(--display);white-space:nowrap}.brand svg{width:35px;height:45px;color:#a3ab73}.brand-accent{color:var(--gold)}.brand small{display:block;margin-top:var(--space-2);font:500 .625rem/1.2 var(--body);letter-spacing:.3em;color:var(--text)}.desktop-nav{display:flex;gap:clamp(1rem,2.3vw,2rem);font-size:var(--text-sm)}.desktop-nav a,.text-button{color:var(--text)}.desktop-nav a:hover,.site-footer a:hover{color:var(--gold)}.header-actions,#nl,#nu{display:flex;align-items:center;gap:var(--space-4)}.text-button,.theme-toggle{background:none;border:0;min-height:44px;font-size:var(--text-sm);padding:var(--space-2)}.theme-toggle{font-size:1.4rem;min-width:44px;color:var(--gold)}.button{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-6);min-height:48px;background:var(--button);color:#fff7e9;border:1px solid transparent;border-radius:5px;padding:var(--space-3) var(--space-6);font-weight:500;font-size:var(--text-sm);line-height:1.5;transition:background .2s,transform .2s}.button:hover{background:#653c17;transform:translateY(-1px)}.button.compact{min-height:44px;padding:var(--space-2) var(--space-6);background:#a86b33}.menu-toggle{display:none}.mobile-nav{padding:var(--space-4);border-block:1px solid var(--line)} .study{position:relative;max-width:1536px;margin:0 auto;aspect-ratio:1429/704;isolation:isolate}.study-art{position:absolute;inset:0;z-index:-1;overflow:hidden}.study-art img{width:100%;height:auto;display:block;margin-top:-3.8%;}.scroll-copy{position:relative;width:59%;margin:0 auto;padding-top:7.1%;text-align:center;color:var(--paper-ink)}.eyebrow{font-size:var(--text-xs);font-weight:500;letter-spacing:.17em;line-height:1.6}.scroll-copy .eyebrow{font-size:clamp(.75rem,.85vw,.85rem);letter-spacing:.24em}h1{font-family:var(--display);font-size:var(--text-hero);font-weight:600;line-height:1.12;letter-spacing:-.035em;margin:var(--space-4) 0}h1 em{color:#78451c}.hero-description{font-size:clamp(.875rem,1.3vw,1.15rem);line-height:1.7}.hero-description strong{font-weight:500}.hero-actions{display:flex;justify-content:center;gap:var(--space-4);margin-top:var(--space-6)}.outline{background:transparent;color:var(--paper-ink);border-color:#88674980}.outline:hover{background:#c9a76c40}.hero-note{font-size:var(--text-xs);color:#65533e;margin:var(--space-3) 0 var(--space-6)}.scroll-foot{display:flex;justify-content:center;gap:var(--space-4);font-size:.65rem;font-weight:600;letter-spacing:.1em}.scroll-foot span+span{border-left:1px solid #8b745c90;padding-left:var(--space-4)}.mobile-break{display:none} .feature-strip{max-width:1320px;margin:-1rem auto 0;padding:0 var(--space-8);display:grid;grid-template-columns:repeat(4,1fr);position:relative;z-index:2}.feature-strip>a{padding:var(--space-6);border:1px solid var(--line);background:var(--surface);position:relative;transition:background .2s}.feature-strip>a+a{border-left:0}.feature-strip>a:hover{background:#272b20}.feature-index{color:var(--gold);font-size:var(--text-xs);letter-spacing:.12em}.feature-strip h2{font:600 var(--text-xl)/1.2 var(--display);margin:var(--space-4) 0 var(--space-2)}.feature-strip p{font-size:var(--text-sm);color:var(--muted);line-height:1.65;max-width:240px}.feature-arrow{position:absolute;right:16px;top:16px;color:var(--gold)}.quiet-line{text-align:center;padding:var(--space-8) var(--space-4) var(--space-16)}.quiet-line p{font:italic 500 2rem/1.3 var(--display);color:var(--text)}.quiet-line>span{display:block;margin-top:var(--space-4);font-size:var(--text-xs);letter-spacing:.22em;color:var(--muted)}.section-width{max-width:1200px;margin:auto;padding-inline:var(--space-8)}.process{display:grid;grid-template-columns:1fr 1fr;gap:clamp(2rem,7vw,7rem);padding-block:var(--space-16);border-top:1px solid var(--line)}.section-intro .eyebrow,.author-note .eyebrow{color:var(--gold)}.section-intro h2,.author-note h2{font:500 var(--text-heading)/1.1 var(--display);letter-spacing:-.02em;margin:var(--space-6) 0}.section-intro h2 em,.author-note h2 em{color:var(--gold)}.section-intro>p,.author-note>p{font-size:var(--text-base);color:var(--muted);max-width:450px;margin-bottom:var(--space-6)}.underlined{display:inline-block;padding-block:var(--space-3);border-bottom:1px solid var(--gold);font-size:var(--text-sm)}.underlined span{margin-left:var(--space-4);color:var(--gold)}.process-list{list-style:none;padding:0;margin:0}.process-list li{display:flex;gap:var(--space-6);padding-block:var(--space-6);border-bottom:1px solid var(--line)}.process-list li:first-child{padding-top:0}.process-list li>span{font:500 2rem var(--display);color:var(--gold)}.process-list h3{font:600 1.7rem var(--display);margin-bottom:var(--space-2)}.process-list p{font-size:var(--text-sm);color:var(--muted)} .author-section{display:grid;grid-template-columns:1.2fr 1fr;gap:var(--space-16);padding-block:var(--space-16);border-top:1px solid var(--line)}.author-invitation{border:1px solid var(--line);padding:var(--space-12);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:var(--surface)}.small-rule{width:50px;border-top:1px solid var(--gold);margin-bottom:var(--space-6)}.author-invitation>p{font:italic 500 2rem/1.5 var(--display);margin-bottom:var(--space-8)}.author-invitation>.eyebrow{font-size:.65rem;letter-spacing:.14em;color:var(--muted);margin-bottom:var(--space-4)}.site-footer{border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:var(--space-8);padding-block:var(--space-8)}.footer-brand{font:600 1.5rem var(--display)}.site-footer p,.site-footer small{font-size:var(--text-xs);color:var(--muted)}.site-footer nav{display:flex;flex-wrap:wrap;gap:var(--space-4);font-size:var(--text-xs)} dialog{background:var(--surface);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:var(--space-8);width:min(440px,calc(100% - 32px));max-height:90vh;overflow-y:auto}dialog::backdrop{background:#080b09ba;backdrop-filter:blur(5px)}.dialog-close{position:absolute;right:8px;top:8px;min-height:44px;min-width:44px;background:none;border:0;font-size:1.7rem;cursor:pointer}dialog>.eyebrow{font-size:.65rem;color:var(--gold)}dialog h2{font:600 2.5rem var(--display);margin-top:var(--space-3)}dialog>p{font-size:var(--text-sm);color:var(--muted);margin:var(--space-2) 0 var(--space-6)}.google-button{background:var(--bg);border:1px solid var(--line);border-radius:4px;min-height:48px;width:100%;font-size:var(--text-sm);cursor:pointer}.auth-divider{text-align:center;color:var(--muted);font-size:var(--text-xs);margin-block:var(--space-4)}dialog label{display:block;font-size:var(--text-xs);margin-bottom:var(--space-2)}dialog input{display:block;width:100%;min-height:46px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:4px;padding:var(--space-3);margin-bottom:var(--space-4)}dialog form>.button{width:100%}#auth-message{font-size:var(--text-sm);color:#e6a892;margin-bottom:var(--space-3)}.auth-link{background:none;border:0;color:var(--gold);font-size:var(--text-xs);text-decoration:underline;padding:var(--space-3) 0;min-height:44px;cursor:pointer}.auth-switch{margin:0!important}.auth-legal{font-size:var(--text-xs)!important;margin-bottom:0!important}.auth-legal a{text-decoration:underline} [data-theme=light]{color-scheme:light;--bg:#f1ebdf;--surface:#faf5eb;--text:#293022;--muted:#615f53;--line:#66533440;--gold:#78501f}[data-theme=light] .feature-strip>a:hover{background:#e8decd}[data-theme=light] #auth-message{color:#973b27} @media(min-width:1537px){.study{aspect-ratio:1429/700}.scroll-copy{padding-top:7%}} -@media(max-width:1100px){.desktop-nav{gap:var(--space-4)}.site-header{padding-inline:var(--space-6);gap:var(--space-4)}.brand{font-size:1.7rem}.header-actions,#nl{gap:var(--space-2)}.scroll-copy{padding-top:6.5%}.scroll-copy .eyebrow{letter-spacing:.1em}.hero-note{margin-bottom:var(--space-3)}.scroll-foot{font-size:.6rem;gap:var(--space-2)}.hero-actions{margin-top:var(--space-4)}h1{margin-block:var(--space-3)}.feature-strip{padding-inline:var(--space-6)}.feature-strip>a{padding:var(--space-4)}.feature-index{font-size:.65rem}.study{aspect-ratio:1429/760}.study-art img{height:100%;object-fit:fill;margin:0}.scroll-copy{padding-top:10%}} +@media(max-width:1100px){.desktop-nav{gap:var(--space-4)}.site-header{padding-inline:var(--space-6);gap:var(--space-4)}.brand{font-size:1.7rem}.header-actions,#nl,#nu{gap:var(--space-2)}.scroll-copy{padding-top:6.5%}.scroll-copy .eyebrow{letter-spacing:.1em}.hero-note{margin-bottom:var(--space-3)}.scroll-foot{font-size:.6rem;gap:var(--space-2)}.hero-actions{margin-top:var(--space-4)}h1{margin-block:var(--space-3)}.feature-strip{padding-inline:var(--space-6)}.feature-strip>a{padding:var(--space-4)}.feature-index{font-size:.65rem}.study{aspect-ratio:1429/760}.study-art img{height:100%;object-fit:fill;margin:0}.scroll-copy{padding-top:10%}} @media(max-width:900px){.desktop-nav{display:none}.menu-toggle{display:flex;flex-direction:column;gap:6px;justify-content:center;align-items:center;width:44px;height:44px;border:1px solid var(--line);background:none;border-radius:4px}.menu-toggle span{display:block;width:18px;border-top:1px solid var(--text)}.mobile-nav:not(.hidden){display:flex;justify-content:space-around;gap:var(--space-4);font-size:var(--text-sm)}.scroll-copy{width:63%;padding-top:10%}.hero-description{font-size:.85rem}.hero-actions .button{font-size:.75rem;gap:var(--space-3);padding:var(--space-2) var(--space-4);min-height:44px}.scroll-foot{display:none}.feature-strip{grid-template-columns:repeat(2,1fr)}.feature-strip>a:nth-child(3){border-left:1px solid var(--line)}.feature-strip>a:nth-child(n+3){border-top:0}.process,.author-section{gap:var(--space-8)}.author-invitation{padding:var(--space-6)}.site-footer{flex-wrap:wrap}} @media(max-width:700px){.site-header{min-height:80px;padding:var(--space-3) var(--space-4);gap:var(--space-2)}.brand{font-size:1.6rem;gap:var(--space-2)}.brand svg{width:27px;height:36px}.brand small{font-size:.5rem;letter-spacing:.2em}.header-actions{gap:0}.header-actions .compact{display:none}.text-button{font-size:var(--text-xs);padding-inline:var(--space-2)}.theme-toggle{min-width:32px}.menu-toggle{border:0;width:34px}.mobile-nav:not(.hidden){flex-wrap:wrap}.study{aspect-ratio:auto;min-height:540px;margin:0 var(--space-2)}.study-art img{width:100%;height:100%;object-fit:fill;margin:0}.scroll-copy{width:77%;padding-top:78px;padding-bottom:80px}.scroll-copy .eyebrow{font-size:.625rem;line-height:1.6;letter-spacing:.16em;max-width:240px;margin:auto}h1{font-size:3.5rem;line-height:.95;margin:var(--space-6) 0}.mobile-break{display:block}.hero-description{font-size:var(--text-sm);line-height:1.65}.hero-description br{display:none}.hero-actions{flex-direction:column;align-items:stretch;gap:var(--space-3);margin-top:var(--space-6)}.hero-actions .button{font-size:var(--text-sm);min-height:46px}.hero-note{font-size:.625rem;margin-top:var(--space-3)}.feature-strip{padding-inline:var(--space-4);margin-top:var(--space-4);gap:0}.feature-strip>a{padding:var(--space-4)}.feature-strip h2{font-size:1.5rem;min-height:2.4em;margin-top:var(--space-3)}.feature-strip p{font-size:var(--text-xs)}.feature-index{font-size:.625rem;letter-spacing:.06em}.feature-arrow{display:none}.quiet-line{padding-block:var(--space-8) var(--space-12)}.quiet-line p{font-size:1.75rem}.quiet-line>span{font-size:.625rem;letter-spacing:.1em}.section-width{padding-inline:var(--space-6)}.process,.author-section{grid-template-columns:1fr;padding-block:var(--space-12);gap:var(--space-12)}.eyebrow{font-size:.625rem}.process-list li{gap:var(--space-4)}.author-invitation{padding:var(--space-8)}.site-footer{gap:var(--space-6)}.site-footer nav a{min-height:44px;display:inline-flex;align-items:center}} @media(max-width:700px){.study-art{display:block;width:100%;height:100%}.study-art img{position:absolute;inset:0;width:100%;height:100%;max-width:none}.scroll-copy{width:69%}.hero-description br::after{content:" "}} diff --git a/pricing-init.js b/pricing-init.js index 741fe30..6465696 100644 --- a/pricing-init.js +++ b/pricing-init.js @@ -2,7 +2,8 @@ firebase.initializeApp(FIREBASE_CONFIG); document.querySelectorAll('.faq-item').forEach(function(item){item.querySelector('.faq-q').addEventListener('click',function(){item.classList.toggle('open')})}); document.querySelectorAll('.checkout-tier').forEach(function(btn){btn.addEventListener('click',async function(){ - var user=firebase.auth().currentUser; + // Wait for the restored session rather than reading currentUser before Firebase has loaded it. + var user=await new Promise(function(resolve){var stop=firebase.auth().onAuthStateChanged(function(u){stop();resolve(u)})}); if(!user){alert('Please sign in first at the home page');window.location.href='index.html';return} var plan=btn.dataset.plan;btn.textContent='Redirecting...';btn.disabled=true; try{ diff --git a/pricing.html b/pricing.html index be952fe..1b114d2 100644 --- a/pricing.html +++ b/pricing.html @@ -161,6 +161,6 @@

Frequently Asked Questions

- + diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js index 3f76422..bcffd0a 100644 --- a/scripts/browser-smoke.js +++ b/scripts/browser-smoke.js @@ -340,6 +340,25 @@ function pdf(){ await page.locator('#genre-override').selectOption('selfHelp'); assert.equal(await page.evaluate(()=>AuthorScrollsEditor.getAnalysis().scores.dialogue),null); assert.equal(await page.evaluate(()=>AuthorScrollsEditor.getText()),longBook,'Genre switches preserve source'); + // A signed-in author who goes back to the homepage stays signed in: the nav offers the + // workspace, not Sign in, and the sign-up link the demo banner uses goes straight to the app. + await page.goto(base); + await page.locator('#nu:not(.hidden)').waitFor(); + assert.equal(await page.locator('#nl').isVisible(),false,'no Sign in / Get started for a signed-in author'); + assert.equal(await page.evaluate(()=>localStorage.getItem('ml_signed_in')),'1','the workspace leaves the session hint'); + await page.goto(base+'/index.html#signup'); + await page.waitForURL(/app\.html/); + await page.locator('#lib-loading.hidden').waitFor({state:'attached'}); + // Sign out from the homepage nav: the signed-out buttons return and the hint is gone. + await page.goto(base); + await page.locator('#nav-signout').click(); + await page.locator('#nl:not(.hidden)').waitFor(); + assert.equal(await page.locator('#nu').isVisible(),false,'Open workspace hidden after sign-out'); + assert.equal(await page.evaluate(()=>localStorage.getItem('ml_signed_in')),null,'sign-out clears the hint'); + await page.locator('#nav-signin').click(); + await page.locator('#auth-dialog[open]').waitFor(); + await page.keyboard.press('Escape'); + console.log('PASS returning author: homepage keeps the session, sign-up link opens the workspace, sign out from the nav'); // Workspace demo: a visitor opens the real workspace on a public-domain sample with no // account, no cloud, no API call, and nothing left in browser storage. let apiCalls=0;page.on('request',req=>{if(req.url().includes('/api/'))apiCalls++;}); 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'); +});