From 862e65b9de01bac6ed17a4e55b53784676e39580 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 02:15:13 +0000 Subject: [PATCH 1/5] Blurbs: the author's sentences with a stated basis, never lines the engine wrote Every sentence the Blurb Generator showed was real, but the claims made about them were not. A self-help book was given a protagonist (the literal fallback string "the protagonist"), a tone ("Dark/Suspense" from more than five dark words in seventy thousand), a six-question story framework filled by the first sentence in each slice containing a cue word ("How does it get worse?" was the first middle sentence containing "but"), and five blurbs stitched together with fiction filler nowhere in the book ("must face a choice that will change everything", "one chance to survive"). Nonfiction now gets blurb material, not blurbs: the author's own sentences by the job a back cover needs them to do (the promise, the problem it names, who it is for, the author's stake), each with the section it came from, plus the method and the proof from Document Intelligence's per-chapter extraction. No prose is written for the author. Fiction keeps the six questions, and each answer now states the basis it was chosen on ("first sentence in the middle containing 'but'"; "no sentence in the first quarter contains a change cue"). The five shapes assemble only the manuscript's sentences; a beat the book did not supply is shown as a gap to write, and every shape leaves its closing line to the author. Tone is a rate per 1,000 words; a leaning needs 1.5/1K, and a handful of dark words in a long book is not "dark". A protagonist is named only when a recurring name exists. Also: qualified names now rank by mentions (evidence decides whether a word is a name; mentions decide its place), so a lead who opens most of her sentences no longer ranks below a secondary character who sits mid-sentence. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- analyzer-worker.js | 2 +- analyzer.js | 252 ++++++++++++++++++++------------------- app.html | 6 +- app.js | 73 ++++++++---- blurbs.test.js | 69 +++++++++++ scripts/browser-smoke.js | 8 ++ styles.css | 11 ++ 7 files changed, 269 insertions(+), 152 deletions(-) create mode 100644 blurbs.test.js diff --git a/analyzer-worker.js b/analyzer-worker.js index 15dc32b..db821bf 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=29'); +importScripts('prose-context.js?v=1', 'prose-norms.js?v=4', 'analyzer.js?v=30'); // Note: _currentVersion tracking here is structurally inert — the Worker JS runtime // is single-threaded, so _currentVersion cannot change while Analyzer.analyze() runs diff --git a/analyzer.js b/analyzer.js index d529dd8..9b64d68 100644 --- a/analyzer.js +++ b/analyzer.js @@ -1712,7 +1712,10 @@ const Analyzer = { }); const list = Object.entries(candidates) .filter(([_, d]) => d.mentions >= 3 && (d.mid >= 2 || d.dialogueCount >= 1 || d.possessive >= 1)) - .sort((a, b) => (b[1].mid + b[1].dialogueCount + b[1].possessive) - (a[1].mid + a[1].dialogueCount + a[1].possessive) || b[1].mentions - a[1].mentions) + // Evidence decides whether a word is a name; once it is, how often the book mentions it + // decides its place. A lead who opens most of her sentences must not rank below a + // secondary character who happens to sit mid-sentence. + .sort((a, b) => b[1].mentions - a[1].mentions || (b[1].mid + b[1].dialogueCount + b[1].possessive) - (a[1].mid + a[1].dialogueCount + a[1].possessive)) .slice(0, 40) .map(([name, d]) => ({ name, mentions: d.mentions, midSentence: d.mid, evidenceCount: d.mid + d.dialogueCount + d.possessive, @@ -2813,87 +2816,99 @@ const Analyzer = { // ======================== // BLURB ENGINE // ======================== + // Back-cover material. Fiction: the six-question framework, each answer carrying the + // basis it was chosen on (which cue word, in which slice of the book), assembled into five + // shapes whose missing beats are shown as gaps rather than filled with lines the author + // never wrote. Nonfiction: no protagonist, no crisis; the sentences a back cover needs, + // grouped by role and labelled with where they came from. Tone is a measured rate. generateBlurbs(text, characters, genre, mode) { if (mode !== 'book' && text.split(/\s+/).length < 5000) { return { available: false, reason: 'Blurbs are generated for full manuscripts (book mode or 5000+ words). Upload a complete manuscript to unlock blurb suggestions.' }; } - - // Strip chapter headings from the text for clean extraction const cleanText = text.replace(/^(chapter\s+\d+|chapter\s+[a-z]+|part\s+\d+|part\s+[a-z]+)\s*$/gim, '').replace(/\n{3,}/g, '\n\n'); const paragraphs = cleanText.split(/\n\s*\n/).filter(p => p.trim().length > 10); const lower = cleanText.toLowerCase(); - const totalWords = cleanText.split(/\s+/).length; - const charNames = characters.list.map(c => c.name); - const protagonist = charNames[0] || 'the protagonist'; + const totalWords = (cleanText.match(/\b\w+\b/g) || []).length; + const perK = re => Math.round((lower.match(re) || []).length / Math.max(totalWords, 1) * 1000 * 10) / 10; + const toneRates = { + dark: perK(/\b(death|murder|blood|dark|shadow|kill|terror|horror|fear)\b/g), + romantic: perK(/\b(love|heart|kiss|passion|desire|romance|attraction|beautiful)\b/g), + comic: perK(/\b(laugh|smile|grin|joke|ridiculous|absurd|hilarious|funny)\b/g), + action: perK(/\b(fight|battle|war|weapon|chase|escape|explosion|attack|run)\b/g) + }; + const toneNames = { dark: 'dark / suspense', romantic: 'romantic', comic: 'light / comic', action: 'action' }; + const ranked = Object.entries(toneRates).sort((a, b) => b[1] - a[1]); + // A leaning needs a rate, not a count: five "fear"s in seventy thousand words is nothing. + const toneDetected = ranked[0][1] >= 1.5 ? 'leaning ' + toneNames[ranked[0][0]] : 'no dominant tone vocabulary'; + + if (this.isNonfiction(genre)) return this._blurbMaterial(text, { totalWords, toneRates, toneDetected }); + + const charNames = (characters && characters.applicable !== false && characters.list ? characters.list : []).map(c => c.name); + const protagonist = charNames[0] || null; const antagonist = charNames.length > 1 ? charNames[1] : null; const genreLabel = genre.label || 'Fiction'; + const who = protagonist || '[the protagonist — no recurring name was found]'; + + // Each answer states how it was chosen. The author can disagree with a stated basis; they + // cannot disagree with "extracted from manuscript". + const pick = (section, re, where, cueList) => { + const m = section.match(re); + if (m) return { text: this._extractSentenceAt(section, m.index), basis: 'first sentence in the ' + where + ' containing \u201C' + m[0] + '\u201D' }; + return { text: '', basis: 'no sentence in the ' + where + ' contains ' + cueList }; + }; + const slice = (a, b) => paragraphs.slice(Math.floor(paragraphs.length * a), Math.ceil(paragraphs.length * b)).join(' '); + const opening = slice(0, 0.15); + let statusQuo; + const wantRe = protagonist ? new RegExp(protagonist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s+(wanted|needed|wished|hoped|dreamed|longed|craved|sought|was trying|had always|lived|worked|spent)', 'i') : null; + const wantMatch = wantRe && opening.match(wantRe); + if (wantMatch) statusQuo = { text: this._extractSentenceAt(opening, wantMatch.index), basis: 'first sentence in the opening 15% where ' + protagonist + ' wants, needs or hopes for something' }; + else statusQuo = { text: this._extractFirstMeaningfulSentence(opening, protagonist || ''), basis: protagonist ? 'the first sentence of the opening 15% that mentions ' + protagonist + ' (no wanting or hoping sentence was found)' : 'the first sentence of the manuscript (no recurring name to look for)' }; + const incitingIncident = pick(slice(0, 0.25), /\b(but then|until|one day|everything changed|suddenly|that's when|then came|arrived|discovered|found out|learned that|received|stumbled|appeared)\b/i, 'first quarter', 'a change cue (but then, until, one day, suddenly, discovered\u2026)'); + const conflict = pick(slice(0.3, 0.6), /\b(but|however|unfortunately|worse|problem|danger|threat|impossible|never|couldn't|wouldn't|refused|betrayed|lied|secret|truth|revealed)\b/i, 'middle (30\u201360%)', 'a conflict cue (but, worse, danger, refused, betrayed\u2026)'); + const attempt = pick(slice(0.5, 0.75), /\b(decided|plan|must|had to|needed to|determined|set out|tried|fought|risked|chose|vowed|promised)\b/i, 'second half (50\u201375%)', 'an attempt cue (decided, must, tried, vowed\u2026)'); + const crisis = pick(slice(0.7, 0.9), /\b(everything|nothing|lost|destroyed|shattered|broken|trapped|impossible|too late|no way|final|last chance|only hope)\b/i, 'last third (70\u201390%)', 'a crisis cue (lost, trapped, too late, last chance\u2026)'); + const stakes = pick(slice(0.7, 1), /\b(lose|death|life|love|freedom|everything|world|family|home|truth|soul|heart|future|hope|survive)\b/i, 'last 30%', 'a stakes cue (lose, life, freedom, family, survive\u2026)'); - // === EXTRACT THE 6-QUESTION FRAMEWORK === - - // Q1: What does the main character want / status quo? - // Look in first 15% of text for character goals and normal life - const openingSection = paragraphs.slice(0, Math.ceil(paragraphs.length * 0.15)).join(' '); - const openingLower = openingSection.toLowerCase(); - const wantPatterns = openingSection.match(new RegExp(protagonist + '\\s+(wanted|needed|wished|hoped|dreamed|longed|craved|sought|was trying|had always|lived|worked|spent)', 'i')); - const statusQuo = wantPatterns ? this._extractSentenceAt(openingSection, wantPatterns.index) : this._extractFirstMeaningfulSentence(openingSection, protagonist); - - // Q2: How does it change? (inciting incident - first 25%) - const earlySection = paragraphs.slice(0, Math.ceil(paragraphs.length * 0.25)).join(' '); - const changePatterns = earlySection.match(/\b(but then|until|one day|everything changed|suddenly|that's when|then came|arrived|discovered|found out|learned that|received|stumbled|appeared)\b/i); - const incitingIncident = changePatterns ? this._extractSentenceAt(earlySection, changePatterns.index) : ''; - - // Q3: How does it get worse? (conflict - middle 30-60%) - const midStart = Math.floor(paragraphs.length * 0.3); - const midEnd = Math.floor(paragraphs.length * 0.6); - const middleSection = paragraphs.slice(midStart, midEnd).join(' '); - const conflictPatterns = middleSection.match(/\b(but|however|unfortunately|worse|problem|danger|threat|impossible|never|couldn't|wouldn't|refused|betrayed|lied|secret|truth|revealed)\b/i); - const conflict = conflictPatterns ? this._extractSentenceAt(middleSection, conflictPatterns.index) : ''; - - // Q4: How does character try to fix it? (attempts - middle 50-75%) - const attemptStart = Math.floor(paragraphs.length * 0.5); - const attemptEnd = Math.floor(paragraphs.length * 0.75); - const attemptSection = paragraphs.slice(attemptStart, attemptEnd).join(' '); - const attemptPatterns = attemptSection.match(/\b(decided|plan|must|had to|needed to|determined|set out|tried|fought|risked|chose|vowed|promised)\b/i); - const attempt = attemptPatterns ? this._extractSentenceAt(attemptSection, attemptPatterns.index) : ''; - - // Q5: How does it make things worse? (crisis - 70-90%) - const crisisStart = Math.floor(paragraphs.length * 0.7); - const crisisEnd = Math.floor(paragraphs.length * 0.9); - const crisisSection = paragraphs.slice(crisisStart, crisisEnd).join(' '); - const crisisPatterns = crisisSection.match(/\b(everything|nothing|lost|destroyed|shattered|broken|trapped|impossible|too late|no way|final|last chance|only hope)\b/i); - const crisis = crisisPatterns ? this._extractSentenceAt(crisisSection, crisisPatterns.index) : ''; - - // Q6: What is at stake? (stakes - throughout but especially last 30%) - const lateSection = paragraphs.slice(Math.floor(paragraphs.length * 0.7)).join(' '); - const stakesPatterns = lateSection.match(/\b(lose|death|life|love|freedom|everything|world|family|home|truth|soul|heart|future|hope|survive)\b/i); - const stakes = stakesPatterns ? this._extractSentenceAt(lateSection, stakesPatterns.index) : ''; - - // Detect tone markers for the blurbs - const isDark = (lower.match(/\b(death|murder|blood|dark|shadow|kill|terror|horror|fear)\b/g) || []).length > 5; - const isRomantic = (lower.match(/\b(love|heart|kiss|passion|desire|romance|attraction|beautiful)\b/g) || []).length > 5; - const isFunny = (lower.match(/\b(laugh|smile|grin|joke|ridiculous|absurd|hilarious|funny)\b/g) || []).length > 3; - const isAction = (lower.match(/\b(fight|battle|war|weapon|chase|escape|explosion|attack|run)\b/g) || []).length > 5; - - // === BUILD 5 BLURB VARIATIONS === const framework = { statusQuo, incitingIncident, conflict, attempt, crisis, stakes, protagonist, antagonist }; - + const fw = { statusQuo: statusQuo.text, incitingIncident: incitingIncident.text, conflict: conflict.text, attempt: attempt.text, crisis: crisis.text, stakes: stakes.text, protagonist: who }; const blurbs = [ - this._buildBlurb_hookFirst(framework, genreLabel, isDark, isRomantic, isFunny), - this._buildBlurb_questionStyle(framework, genreLabel), - this._buildBlurb_stakesForward(framework, genreLabel, isDark), - this._buildBlurb_characterFocused(framework, genreLabel, isRomantic), - this._buildBlurb_cinematic(framework, genreLabel, isAction, isDark) + this._buildBlurb_hookFirst(fw, genreLabel), + this._buildBlurb_questionStyle(fw, genreLabel), + this._buildBlurb_stakesForward(fw, genreLabel), + this._buildBlurb_characterFocused(fw, genreLabel), + this._buildBlurb_cinematic(fw, genreLabel) ]; - - return { - available: true, - framework, - blurbs, - wordCount: totalWords, - protagonist, - antagonist, - toneDetected: isDark ? 'Dark/Suspense' : isRomantic ? 'Romantic' : isFunny ? 'Light/Comedy' : isAction ? 'Action/Thriller' : 'Literary' + return { available: true, kind: 'framework', framework, blurbs, wordCount: totalWords, protagonist, antagonist, toneRates, toneDetected }; + }, + + // Nonfiction back-cover material: the author's own sentences, by role, each with the section + // it came from. No connective prose is written; composing the blurb stays with the author. + _blurbMaterial(text, meta) { + const sections = this.detectSections(text); + const sectionAt = index => { let label = 'Opening'; for (const s of sections) { if (s.index <= index) label = s.label; else break; } return label; }; + const roles = { + promise: { label: 'The promise', re: /\b(this (book|chapter) (is|was) (for|about|written)|i wrote this|if that('s| is) you|you will (learn|discover|find|see|know|understand)|by the end of (this|the)|i (will|want to|am going to) (show|teach|help|give|hand) you|take a seat|this is for (you|anyone|the))\b/i }, + problem: { label: 'The problem it names', re: /\b(coming up short|invisible weight|performing fine while|you (feel|are|might be|may be) (tired|stuck|overlooked|exhausted|drowning|struggling|failing|afraid|worried)|one paycheck|still sinking|silently drowning|burnout|overwhelmed|told to just work harder)\b/i }, + audience: { label: 'Who it is for', re: /\b(for anyone (who|told)|for those who|whether you('re| are)|if you('re| are| have)|this message is for|anyone (who has|told to))\b/i }, + authorStake: { label: 'The author\u2019s stake', re: /\b(i (have|had|'ve) (lived|been|spent|learned|seen|lost|built|worked)|in my (experience|life|years|twenties)|when i (arrived|was|started|lost|left|came)|i climbed|i grew up|i offer no)\b/i } }; + const material = {}; + const sentenceRe = /[^.!?\n]+[.!?]+/g; + for (const [key, role] of Object.entries(roles)) { + const rows = []; + let m; + sentenceRe.lastIndex = 0; + while ((m = sentenceRe.exec(text)) !== null && rows.length < 4) { + const sentence = m[0].trim(); + // Short lines are often the best cover lines ("Take a seat."); only fragments are skipped. + if (sentence.length < 12 || sentence.length > 320) continue; + if (!role.re.test(sentence.replace(/[\u2018\u2019]/g, "'"))) continue; + rows.push({ text: sentence, index: m.index, section: sectionAt(m.index) }); + } + material[key] = { label: role.label, rows }; + } + return { available: true, kind: 'material', material, wordCount: meta.totalWords, toneRates: meta.toneRates, toneDetected: meta.toneDetected, + note: 'These are your sentences, grouped by the job a back cover needs them to do, with where each came from. No prose is written for you.' }; }, // Helper: clean chapter headings and noise from extracted text @@ -2950,69 +2965,62 @@ const Analyzer = { .trim(); }, + // A missing beat is shown as a gap the author fills, never as a line the engine wrote. + _gap(label) { return '[' + label + ']'; }, + _blurbResult(style, description, parts, sep) { + const text = this._cleanBlurb(parts.filter(Boolean).join(sep || ' ')); + const gaps = (text.match(/\[[^\]]+\]/g) || []).length; + const wordCount = text.replace(/\[[^\]]+\]/g, '').replace(/\n/g, ' ').trim().split(/\s+/).filter(Boolean).length; + return { style, description, text, wordCount, gaps }; + }, // Blurb 1: Hook-first (lead with the most dramatic element) - _buildBlurb_hookFirst(fw, genre, isDark, isRomantic, isFunny) { - let parts = []; - if (fw.crisis) parts.push(this._trimTo(fw.crisis, 40)); - parts.push('But it wasn\'t always this way.'); - if (fw.statusQuo) parts.push(this._trimTo(fw.statusQuo, 35)); - if (fw.incitingIncident) parts.push(this._trimTo(fw.incitingIncident, 30)); - if (fw.conflict) parts.push('Now, ' + this._trimToLower(fw.conflict, 30)); - parts.push('With everything on the line, ' + fw.protagonist + ' must face a choice that will change everything.'); - const blurb = this._cleanBlurb(parts.join(' ')); - return { style: 'Hook First', description: 'Opens with the crisis to grab attention, then rewinds to show how it got there.', text: blurb || 'Could not extract enough story elements.', wordCount: blurb.split(/\s+/).length }; + _buildBlurb_hookFirst(fw) { + return this._blurbResult('Hook First', 'Opens with the crisis to grab attention, then rewinds to show how it got there.', [ + fw.crisis ? this._trimTo(fw.crisis, 40) : this._gap('the crisis \u2014 no sentence found in the last third'), + fw.statusQuo ? this._trimTo(fw.statusQuo, 35) : this._gap('what ' + fw.protagonist + ' wants'), + fw.incitingIncident ? this._trimTo(fw.incitingIncident, 30) : this._gap('what changes'), + fw.conflict ? 'Now, ' + this._trimToLower(fw.conflict, 30) : '', + fw.stakes ? this._trimTo(fw.stakes, 25) : this._gap('what is at stake') + ]); }, - // Blurb 2: Question style (end with a provocative question) - _buildBlurb_questionStyle(fw, genre) { - let parts = []; - if (fw.statusQuo) parts.push(this._trimTo(fw.statusQuo, 35)); - if (fw.incitingIncident) parts.push(this._trimTo(fw.incitingIncident, 30)); - if (fw.conflict) parts.push(this._trimTo(fw.conflict, 30)); - const crisisText = this._trimToLower(fw.crisis || fw.attempt || 'the truth comes out', 20); - parts.push('When ' + crisisText + ', will ' + fw.protagonist + ' lose everything — or find a way to survive?'); - const blurb = this._cleanBlurb(parts.join(' ')); - return { style: 'Question Hook', description: 'Builds to a question that makes the reader need to know the answer.', text: blurb, wordCount: blurb.split(/\s+/).length }; + _buildBlurb_questionStyle(fw) { + return this._blurbResult('Question Hook', 'Builds to a question that makes the reader need to know the answer.', [ + fw.statusQuo ? this._trimTo(fw.statusQuo, 35) : this._gap('what ' + fw.protagonist + ' wants'), + fw.incitingIncident ? this._trimTo(fw.incitingIncident, 30) : this._gap('what changes'), + fw.conflict ? this._trimTo(fw.conflict, 30) : this._gap('what goes wrong'), + fw.crisis || fw.attempt ? 'When ' + this._trimToLower(fw.crisis || fw.attempt, 20) + ',' : this._gap('the crisis'), + this._gap('your closing question \u2014 what must ' + fw.protagonist + ' choose?') + ]); }, - // Blurb 3: Stakes forward (lead with what's at risk) - _buildBlurb_stakesForward(fw, genre, isDark) { - const stakeWord = isDark ? 'survive' : 'hold onto what matters most'; - let parts = [fw.protagonist + ' has one chance to ' + stakeWord + '.']; - if (fw.statusQuo) parts.push(this._trimTo(fw.statusQuo, 30)); - if (fw.incitingIncident) parts.push('Then ' + this._trimToLower(fw.incitingIncident, 25)); - if (fw.conflict) parts.push(this._trimTo(fw.conflict, 25)); - if (fw.attempt) parts.push(this._trimTo(fw.attempt, 25)); - const stakesText = this._trimToLower(fw.stakes || 'everything at stake', 20); - parts.push('Now, with ' + stakesText + ', there\'s no turning back.'); - const blurb = this._cleanBlurb(parts.join(' ')); - return { style: 'Stakes Forward', description: 'Opens with what the character stands to lose, creating immediate urgency.', text: blurb, wordCount: blurb.split(/\s+/).length }; + _buildBlurb_stakesForward(fw) { + return this._blurbResult('Stakes Forward', 'Opens with what the character stands to lose, creating immediate urgency.', [ + fw.stakes ? this._trimTo(fw.stakes, 25) : this._gap('what is at stake \u2014 no sentence found in the last 30%'), + fw.statusQuo ? this._trimTo(fw.statusQuo, 30) : '', + fw.incitingIncident ? 'Then ' + this._trimToLower(fw.incitingIncident, 25) : this._gap('what changes'), + fw.conflict ? this._trimTo(fw.conflict, 25) : '', + fw.attempt ? this._trimTo(fw.attempt, 25) : this._gap('what ' + fw.protagonist + ' tries'), + this._gap('the turning point \u2014 your line') + ]); }, - // Blurb 4: Character-focused (who is this person and why should we care?) - _buildBlurb_characterFocused(fw, genre, isRomantic) { - let parts = []; - if (fw.statusQuo) parts.push(this._trimTo(fw.statusQuo, 35)); - if (isRomantic && fw.incitingIncident) parts.push('Everything changes when ' + this._trimToLower(fw.incitingIncident, 30)); - else if (fw.incitingIncident) parts.push(this._trimTo(fw.incitingIncident, 30)); - if (fw.conflict) parts.push(this._trimTo(fw.conflict, 25)); - const startText = this._trimToLower(fw.statusQuo || 'an ordinary life', 12); - const stakeText = this._trimToLower(fw.stakes || 'everything', 15); - parts.push('What started as ' + startText + ' has become a fight for ' + stakeText + '.'); - const blurb = this._cleanBlurb(parts.join(' ')); - return { style: 'Character Portrait', description: 'Centers the character — who they are, what they want, and what threatens them.', text: blurb, wordCount: blurb.split(/\s+/).length }; + _buildBlurb_characterFocused(fw) { + return this._blurbResult('Character Portrait', 'Centers the character \u2014 who they are, what they want, and what threatens them.', [ + fw.statusQuo ? this._trimTo(fw.statusQuo, 35) : this._gap('who ' + fw.protagonist + ' is'), + fw.incitingIncident ? this._trimTo(fw.incitingIncident, 30) : this._gap('what changes'), + fw.conflict ? this._trimTo(fw.conflict, 25) : '', + fw.stakes ? this._trimTo(fw.stakes, 20) : this._gap('what threatens them now') + ]); }, - // Blurb 5: Cinematic (short, punchy, visual) - _buildBlurb_cinematic(fw, genre, isAction, isDark) { - let lines = []; - if (fw.statusQuo) lines.push(this._trimTo(fw.statusQuo, 18)); - if (fw.incitingIncident) lines.push(this._trimTo(fw.incitingIncident, 15)); - if (fw.conflict) lines.push(this._trimTo(fw.conflict, 15)); - const closer = isDark ? 'Some doors, once opened, can never be closed.' : isAction ? 'The countdown has begun.' : 'Nothing will ever be the same.'; - lines.push(closer); - const blurb = this._cleanBlurb(lines.join('\n\n')); - return { style: 'Cinematic', description: 'Short, punchy lines with white space. Reads like a movie trailer.', text: blurb, wordCount: blurb.replace(/\n/g,' ').split(/\s+/).length }; + _buildBlurb_cinematic(fw) { + return this._blurbResult('Cinematic', 'Short, punchy lines with white space. Reads like a movie trailer.', [ + fw.statusQuo ? this._trimTo(fw.statusQuo, 18) : this._gap('one line: who'), + fw.incitingIncident ? this._trimTo(fw.incitingIncident, 15) : this._gap('one line: what changes'), + fw.conflict ? this._trimTo(fw.conflict, 15) : this._gap('one line: what goes wrong'), + this._gap('your closing line') + ], '\n\n'); }, // Helper: trim text to ~N words, ending at a sentence boundary diff --git a/app.html b/app.html index dd1bcae..e3fa87b 100644 --- a/app.html +++ b/app.html @@ -5,7 +5,7 @@ AuthorScrolls - Author's Best Buddy - + @@ -439,7 +439,7 @@

Choos - + @@ -460,7 +460,7 @@

Choos - + diff --git a/app.js b/app.js index b6929ba..1542959 100644 --- a/app.js +++ b/app.js @@ -1804,56 +1804,77 @@ function renderBlurbs(r){ const d=$('ed-blurbs');if(!d||!r)return;d.className='ms-page dark-page'; const b=r.blurbs; if(!b||!b.available){ - d.innerHTML='

Blurb Generator

'+(b?.reason||'Upload a full manuscript to generate blurb suggestions.')+'

Blurbs are generated for books and manuscripts over 5,000 words. The engine extracts your story\'s core elements — protagonist, conflict, stakes — and assembles 5 variations in different styles.

'; + d.innerHTML='

Blurb Generator

'+(b?.reason||'Upload a full manuscript to generate blurb suggestions.')+'

Blurbs are generated for books and manuscripts over 5,000 words.

'; return; } - let h='

Blurb Generator

'; - h+='
'; - h+='Protagonist: '+esc(b.protagonist)+''; - if(b.antagonist)h+='Antagonist: '+esc(b.antagonist)+''; - h+='Tone: '+esc(b.toneDetected)+''; - h+='Words: '+b.wordCount.toLocaleString()+''; - h+='
'; - - // 6-question framework breakdown + const tone=b.toneRates?Object.entries(b.toneRates).map(([k,v])=>k+' '+v+'/1K').join(' \u00B7 ')+(b.toneDetected?' \u2192 '+b.toneDetected:''):''; + let h=''; + if(b.kind==='material'){ + // Nonfiction: material by role, from the author's own sentences. No protagonist, no crisis. + h+='

Blurb material

Words: '+b.wordCount.toLocaleString()+''+(tone?'Tone vocabulary: '+esc(tone)+'':'')+'

'+esc(b.note||'')+'

'; + const section=(title,rows,empty)=>'

'+esc(title)+'

'+(rows.length?rows.map(row=>'
\u201C'+esc(row.text)+'\u201D
'+esc(row.section||'')+'
').join(''):'

'+esc(empty)+'

')+'
'; + for(const key of ['promise','problem','audience','authorStake']){ + const m=b.material[key];if(!m)continue; + h+=section(m.label,m.rows,'No sentence in the manuscript matched this role. If the book does this, it does it in words the engine does not recognise \u2014 or it is worth writing.'); + } + h+='

The method

Reading chapters\u2026
'; + h+='

The proof

Reading chapters\u2026
'; + d.innerHTML=h; + _renderBlurbMaterialFromIntel(r); + return; + } + h+='

Blurb Generator

'; + if(b.protagonist)h+='Protagonist: '+esc(b.protagonist)+''; + else h+='Protagonist: no recurring name found'; + if(b.antagonist)h+='Second lead: '+esc(b.antagonist)+''; + if(tone)h+='Tone vocabulary: '+esc(tone)+''; + h+='Words: '+b.wordCount.toLocaleString()+'
'; const fw=b.framework; - h+='

Story Framework (extracted from manuscript)

'; + h+='

Story Framework each answer says how it was chosen

'; const questions=[ - {q:'What does the character want?',a:fw.statusQuo}, - {q:'How does it change?',a:fw.incitingIncident}, - {q:'How does it get worse?',a:fw.conflict}, - {q:'How do they try to fix it?',a:fw.attempt}, - {q:'How does that make it worse?',a:fw.crisis}, - {q:'What is at stake?',a:fw.stakes} + {q:'What does the character want?',a:fw.statusQuo},{q:'How does it change?',a:fw.incitingIncident},{q:'How does it get worse?',a:fw.conflict}, + {q:'How do they try to fix it?',a:fw.attempt},{q:'How does that make it worse?',a:fw.crisis},{q:'What is at stake?',a:fw.stakes} ]; questions.forEach((q,i)=>{ - h+='
Q'+(i+1)+': '+q.q+'
'+(q.a?esc(q.a):'Could not extract — try adding clearer story beats')+'
'; + const ans=q.a||{}; + h+='
Q'+(i+1)+': '+q.q+'
' + +'
'+(ans.text?esc(ans.text):'No sentence found')+'
' + +'
'+esc(ans.basis||'')+'
'; }); h+='
'; - - // 5 Blurb variations b.blurbs.forEach((bl,i)=>{ - h+='

'+esc(bl.style)+'

'+bl.wordCount+' words
'; + h+='

'+esc(bl.style)+'

'+bl.wordCount+' words'+(bl.gaps?' \u00B7 '+bl.gaps+' gap'+(bl.gaps===1?'':'s')+' to write':'')+'
'; h+='
'+esc(bl.description)+'
'; - h+='
'+esc(bl.text).replace(/\n/g,'
')+'
'; + // Gaps are the beats the manuscript did not supply a sentence for; they are yours to write. + const body=esc(bl.text).replace(/\[([^\]]+)\]/g,'$1').replace(/\n/g,'
'); + h+='
'+body+'
'; h+='
'; h+='
'; }); - d.innerHTML=h; - - // Copy buttons d.querySelectorAll('.blurb-copy').forEach(btn=>{btn.addEventListener('click',()=>{ const idx=btn.dataset.idx;const el=$('blurb-'+idx); navigator.clipboard.writeText(el.textContent);btn.textContent='Copied!';setTimeout(()=>{btn.textContent='Copy'},1500); })}); - // Edit buttons - make blurb editable d.querySelectorAll('.blurb-edit').forEach(btn=>{btn.addEventListener('click',()=>{ const idx=btn.dataset.idx;const el=$('blurb-'+idx); if(el.contentEditable==='true'){el.contentEditable='false';el.style.outline='';btn.textContent='Edit'} else{el.contentEditable='true';el.style.outline='1px solid var(--gold-d)';el.style.outlineOffset='4px';el.focus();btn.textContent='Done'} })}); } +// The method and the proof come from Document Intelligence's per-chapter extraction (explicit +// actions; attributed claims and personal evidence), the same rows the Intelligence window shows. +function _renderBlurbMaterialFromIntel(r){ + if(typeof IntelligenceWindow==='undefined'||typeof IntelligenceWindow.data!=='function'){$('bm-method')?.remove();$('bm-proof')?.remove();return} + IntelligenceWindow.data(data=>{ + const method=$('bm-method'),proof=$('bm-proof');if(!method&&!proof)return; + const nf=data&&data.intel&&data.intel.nonfiction;const chapters=(data&&data.parsed&&data.parsed.chapters)||[]; + const title=id=>{const c=chapters.find(x=>x.id===id);return c?(c.title||c.id):''}; + const rows=(list,n)=>(list||[]).slice(0,n).map(row=>'
\u201C'+esc(row.text||row.evidence||'')+'\u201D
'+esc(title(row.chapterId))+'
').join(''); + if(method)method.outerHTML=nf&&(nf.actions||[]).length?rows(nf.actions,5):'

No explicit action was found. If the book gives the reader something to do, it is worth saying so in a sentence the cover can quote.

'; + if(proof)proof.outerHTML=nf&&((nf.claims||[]).length||(nf.personalEvidence||[]).length)?rows(nf.claims,4)+rows(nf.personalEvidence,3):'

No attributed claim or personal evidence was found.

'; + },extractedText,r); +} // TABS (bottom) document.querySelectorAll('.btab').forEach(t=>{t.addEventListener('click',()=>{ diff --git a/blurbs.test.js b/blurbs.test.js new file mode 100644 index 0000000..9f68cf2 --- /dev/null +++ b/blurbs.test.js @@ -0,0 +1,69 @@ +'use strict'; +// Back-cover material must be the author's sentences with a stated basis, never lines the +// engine wrote, and a nonfiction book must not be handed a protagonist and a crisis. +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'); +globalThis.ProseContext=require('./prose-context');globalThis.ProseNorms=require('./prose-norms'); +eval(fs.readFileSync(require.resolve('./analyzer'),'utf8')+'; globalThis.Analyzer=Analyzer;'); + +const INVENTED=/must face a choice|one chance to survive|lose everything|no turning back|Nothing will ever be the same|Some doors, once opened|The countdown has begun|has become a fight for/; + +function selfHelp(){ + const filler='Most people assume that effort is the whole story. Research generally suggests otherwise, and the pattern repeats in every city I have worked in. '; + let t='Introduction\n\nSit with me for a moment. If that’s you, take a seat. I wrote this for you.\n\nNow, perhaps you feel like you are doing everything right and still coming up short—carrying invisible weight, performing fine while struggling.\n\nWhen I arrived in the United States, I carried one bag and a pair of shoes.\n\n'; + for(let c=1;c<=8;c++){t+='Chapter '+c+': Practice '+c+'\n\n';for(let i=0;i<40;i++)t+=filler+'\n\n';t+='Action: Write down one belief you want to test this week.\n\nAccording to a 2019 study, small habits compound.\n\n';} + return t; +} +// Paragraphed like a book (the framework slices by paragraph), with a short first chapter so +// the inciting line at the top of chapter 2 falls inside the first quarter. +function novel(){ + const para=(s,n)=>{let out='';for(let i=0;i{ + const r=Analyzer.analyze(selfHelp(),'selfHelp'); + const b=r.blurbs; + assert.equal(b.available,true); + assert.equal(b.kind,'material'); + assert.ok(!('protagonist' in b),'no protagonist is invented for a self-help book'); + assert.ok(!JSON.stringify(b).match(INVENTED),'no fiction filler anywhere'); + assert.ok(b.material.promise.rows.some(row=>/I wrote this for you/.test(row.text)),JSON.stringify(b.material.promise)); + assert.ok(b.material.problem.rows.some(row=>/coming up short/.test(row.text))); + assert.ok(b.material.authorStake.rows.some(row=>/When I arrived/.test(row.text))); + assert.ok(b.material.promise.rows.every(row=>typeof row.section==='string'),'every row says where it came from'); +}); + +test('fiction answers carry the basis they were chosen on, and missing beats are gaps, not filler',()=>{ + const r=Analyzer.analyze(novel(),'literary'); + const b=r.blurbs; + assert.equal(b.kind,'framework'); + assert.equal(b.protagonist,'Mara'); + for(const key of ['statusQuo','incitingIncident','conflict','attempt','crisis','stakes']){ + assert.ok(b.framework[key].basis.length>20,key+' states its basis'); + } + assert.match(b.framework.statusQuo.basis,/wants, needs or hopes/); + assert.match(b.framework.incitingIncident.basis,/containing “(arrived|one day)”/i); + for(const bl of b.blurbs){ + assert.ok(!INVENTED.test(bl.text),bl.style+' contains an invented line: '+bl.text); + assert.ok(Number.isFinite(bl.gaps)); + } + // Every shape ends in something the author writes, never a stock closer. + assert.ok(b.blurbs.every(bl=>bl.gaps>=1),'each shape leaves at least the closing line to the author'); +}); + +test('tone is a rate per thousand words, and a handful of dark words in a long book is not "dark"',()=>{ + let t='Chapter 1\n\n';for(let i=0;i<1500;i++)t+='She walked to the market and bought bread, then carried it home along the river. '; + t+='The fear passed. The dark came early. Fear again, and the dark, and a shadow, and one more fear.'; + const b=Analyzer.analyze(t,'literary').blurbs; + assert.ok(b.toneRates&&Number.isFinite(b.toneRates.dark)); + assert.ok(b.toneRates.dark<1,'six words in twenty thousand: '+b.toneRates.dark); + assert.equal(b.toneDetected,'no dominant tone vocabulary'); +}); diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js index fa56509..1fd1b86 100644 --- a/scripts/browser-smoke.js +++ b/scripts/browser-smoke.js @@ -263,6 +263,14 @@ function pdf(){ assert.match(nfRail,/Self-Help reader comes for/i,nfRail); assert.ok(!/Strong dialogue|Fast pacing|Build tension/.test(nfRail),'a self-help book is not judged against fiction goals'); assert.match(await page.locator('#rp-scores').innerText(),/imperatives/,'self-help cards show the counts behind the score'); + // Blurbs for nonfiction: material by role from the author's sentences, no protagonist. + await page.locator('.btab[data-p="blurbs"]').click(); + await page.locator('#ed-blurbs .bm-row').first().waitFor(); + await page.locator('#ed-blurbs').getByText('Write down a small experiment',{exact:false}).first().waitFor(); + const blurbText=await page.locator('#ed-blurbs').innerText(); + assert.match(blurbText,/Blurb material/); + assert.ok(!/protagonist|must face a choice|chance to survive/i.test(blurbText),'no fiction frame on a self-help book'); + await page.locator('.btab[data-p="annotated"]').click(); await page.locator('#rp-scores .rsc[data-cat="sh_evidence"]').click(); await page.locator('#rp-detail .rpd-sub').filter({hasText:'fewest attributed claims'}).waitFor(); assert.match(await page.locator('#rp-detail').innerText(),/Built from: .*evidence cues/); diff --git a/styles.css b/styles.css index 59e091a..f50b1dc 100644 --- a/styles.css +++ b/styles.css @@ -358,6 +358,17 @@ body.lib-mode{overflow:auto} .blurb-card{border-left:3px solid var(--gold-d)} .blurb-text{background:var(--surface2);border-radius:var(--rs);padding:.75rem 1rem;font-size:.88rem;line-height:1.7;font-family:'Lora',serif;color:var(--text);white-space:pre-wrap;min-height:60px} .blurb-text[contenteditable="true"]{background:var(--surface3)} +/* A beat the manuscript did not supply: shown as a gap to write, never as a line the engine wrote. */ +.blurb-gap{display:inline-block;background:rgba(212,168,85,.12);color:var(--yellow);border:1px dashed var(--yellow);border-radius:4px;padding:0 .35rem;font-family:Inter,system-ui,sans-serif;font-size:.74rem;font-style:italic;line-height:1.5;vertical-align:baseline} +/* Nonfiction blurb material and framework bases */ +.bm-meta{display:flex;gap:.8rem;flex-wrap:wrap;font-size:.72rem;color:var(--muted);margin-bottom:.4rem} +.bm-note{font-size:.75rem;color:var(--muted);line-height:1.5} +.bm-row{display:grid;grid-template-columns:1fr auto;gap:.6rem;align-items:baseline;padding:.45rem 0;border-bottom:1px solid var(--border)} +.bm-row:last-child{border-bottom:0} +.bm-text{font-family:'Lora',serif;font-size:.88rem;line-height:1.6;color:var(--text)} +.bm-src{font-size:.65rem;color:var(--dim);white-space:nowrap} +.bm-empty{font-size:.78rem;color:var(--muted);padding:.3rem 0} +.bm-basis{font-size:.66rem;color:var(--dim);padding-left:.6rem;margin-top:.15rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} /* GENRE SELECT */ .genre-select-wrap{margin-top:.5rem} From f6d1981b146e1f43bc6cc0a63d6f152710fbecb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 02:15:47 +0000 Subject: [PATCH 2/5] Blurb test: only the shapes that once ended on stock closers must end on a gap Hook First and Character Portrait may end on a manuscript sentence when every beat was found; the previous assertion demanded a gap from all five and was stricter than the design. Gates the earlier commit, which was pushed with this one assertion red. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- blurbs.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/blurbs.test.js b/blurbs.test.js index 9f68cf2..8f164c0 100644 --- a/blurbs.test.js +++ b/blurbs.test.js @@ -55,8 +55,13 @@ test('fiction answers carry the basis they were chosen on, and missing beats are assert.ok(!INVENTED.test(bl.text),bl.style+' contains an invented line: '+bl.text); assert.ok(Number.isFinite(bl.gaps)); } - // Every shape ends in something the author writes, never a stock closer. - assert.ok(b.blurbs.every(bl=>bl.gaps>=1),'each shape leaves at least the closing line to the author'); + // The three shapes that used to end on a stock closer now end on a gap the author writes; + // the other two may end on a manuscript sentence when every beat was found. + for(const style of ['Question Hook','Stakes Forward','Cinematic']){ + const bl=b.blurbs.find(x=>x.style===style); + assert.ok(bl.gaps>=1,style+' leaves its closing line to the author'); + assert.match(bl.text,/\[[^\]]+\]\s*$/,style+' ends on a gap, not an invented line'); + } }); test('tone is a rate per thousand words, and a handful of dark words in a long book is not "dark"',()=>{ From fc2a3e59c30f40a2be3a9e8db0ae2728850b42a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 02:22:26 +0000 Subject: [PATCH 3/5] Navigator: name chapters by title and say provenance in words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "blameville · chapter-005 · author_named_concept" showed the author an internal chapter id and a source key. Rows now read "blameville · Chapter 5: Blame · a term the author coined": chapter titles come from the parsed manuscript (with a readable fallback), and each provenance key has a plain-English label. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- app.html | 2 +- intelligence-window.js | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app.html b/app.html index e3fa87b..25310d6 100644 --- a/app.html +++ b/app.html @@ -471,6 +471,6 @@

Choos
- + diff --git a/intelligence-window.js b/intelligence-window.js index 6cc68ef..cd0e8d1 100644 --- a/intelligence-window.js +++ b/intelligence-window.js @@ -36,6 +36,7 @@ const IntelligenceWindow = (() => { if (!text.trim()) return null; const analysis = window.AuthorScrollsEditor?.getAnalysis(); latest = { ...IntelligencePipeline.build(text, analysis), analysis, sig: analysisSignature(analysis) }; + noteChapters(latest); return latest; } // Building the pipeline on a large manuscript (chapter parsing + a POV/character @@ -59,11 +60,24 @@ const IntelligenceWindow = (() => { IntelligencePipeline.buildAsync(text, analysis).then(result => { if (token !== buildToken) return; // superseded by a newer request — discard latest = { ...result, analysis, sig: analysisSignature(analysis) }; + noteChapters(latest); onReady(latest); }).catch(() => { if (token === buildToken) onReady(build()); }); } const empty = text => `

${esc(text)}

`; - const evidence = rows => rows.map(row => `${esc(row.chapterId || '')} · ${esc(row.text || row.evidence || '')}`).join(''); + // Internal identifiers never reach the author: a chapter is named by its title, and a + // row's provenance is said in words ("a term the author coined"), not as a source key. + const chapterTitles = new Map(); + function noteChapters(data) { + chapterTitles.clear(); + ((data && data.parsed && data.parsed.chapters) || []).forEach(c => chapterTitles.set(c.id, c.title || c.id)); + } + const chapterLabel = id => chapterTitles.get(id) || String(id || '').replace(/^chapter-0*(\d+)$/, 'Chapter $1'); + const SOURCE_WORDS = { author_named_concept: 'a term the author coined', explicit_action: 'an explicit action', + explicit_reflection_question: 'a reflection question', explicit_recommendations: 'a recommendation', + deterministic: 'measured from the text', languagetool: 'grammar check' }; + const sourceLabel = s => SOURCE_WORDS[s] || String(s || '').replace(/_/g, ' '); + const evidence = rows => rows.map(row => `${esc(chapterLabel(row.chapterId))} · ${esc(row.text || row.evidence || '')}`).join(''); let renderToken = 0; function render() { const host = $('intel-body'); @@ -85,7 +99,7 @@ const IntelligenceWindow = (() => { if(nf){ const groups=[['Concepts',nf.concepts],['Attributed claims',nf.claims],['Personal evidence',nf.personalEvidence],['Reflection questions',nf.reflectionQuestions],['Actions',nf.actions],['Recommendations',nf.recommendations]]; host.innerHTML='

Nonfiction intelligence · Source-linked signals for your review, not fact-checking or a judgment of your voice.

'+ - groups.map(([label,rows])=>`

${label} ${rows.length}

${rows.slice(0,12).map(row=>`
${esc(row.name||row.text||row.evidence)}${esc(row.chapterId)} · ${esc(row.source)}
`).join('')||empty('No explicit signals detected. This does not mean your book lacks them.')}
`).join(''); + groups.map(([label,rows])=>`

${label} ${rows.length}

${rows.slice(0,12).map(row=>`
${esc(row.name||row.text||row.evidence)}${esc(chapterLabel(row.chapterId))} · ${esc(sourceLabel(row.source))}
`).join('')||empty('No explicit signals detected. This does not mean your book lacks them.')}
`).join(''); }else{ const sections = [ ['Characters', i.characterLedger.characters.slice(0,12).map(c => `
${esc(c.name)}${c.mentions} mentions · ${c.chapterIds.length} chapters${c.facts.slice(0,2).map(f=>`${esc(f.predicate)} ${esc(f.object)}`).join('')}
`).join('')], @@ -295,7 +309,7 @@ const IntelligenceWindow = (() => { } if(nf&&(mode==='characters'||mode==='threads')){ const rows=mode==='characters'?nf.concepts:[...nf.claims,...nf.personalEvidence]; - host.innerHTML=rows.map(c=>item('data-chapter',c.chapterId,c.name||c.evidence,c.chapterId+' · '+c.source)).join('')||empty('No explicit evidence detected. Review the source text.'); + host.innerHTML=rows.map(c=>item('data-chapter',c.chapterId,c.name||c.evidence,chapterLabel(c.chapterId)+' · '+sourceLabel(c.source))).join('')||empty('No explicit evidence detected. Review the source text.'); } else if(mode==='characters')host.innerHTML=data.intel.characterLedger.characters.map(c=>item('data-character',c.id,c.name,`${c.chapterIds.length} chapters · ${c.threads.length} threads`)).join('')||empty('No recurring characters detected.'); else if(mode==='threads')host.innerHTML=data.intel.narrativeMomentum.arcs.map(t=>item('data-thread',t.id,t.label,`${t.pressure} · ${t.recurrence} signals`)).join('')||empty('No narrative threads detected.'); @@ -390,9 +404,10 @@ const IntelligenceWindow = (() => { if (latest && latest.text === text && latest.sig === sig) { onReady(latest); return; } IntelligencePipeline.buildAsync(text, analysis).then(result => { latest = { ...result, analysis, sig }; + noteChapters(latest); onReady(latest); }).catch(() => { - try { latest = { ...IntelligencePipeline.build(text, analysis), analysis, sig }; onReady(latest); } + try { latest = { ...IntelligencePipeline.build(text, analysis), analysis, sig }; noteChapters(latest); onReady(latest); } catch (_) { onReady(null); } }); } From 7bb2b91669b46c4e02a9025316e63a8da346c97e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 02:27:19 +0000 Subject: [PATCH 4/5] Header: one row, the title leads, depth by layering The header had no hierarchy: logo, Library, title, save state and four buttons at one weight on one row, with a second row that said the genre twice. It is now one row. The manuscript's title leads at 20px Lora with its eyebrow; the reading context and counts sit under it as a meta line; the mark is the brand and Library a quiet link beside it; Save is the one filled action, Intelligence the outlined one, and the two menus are text. The header is a plane over the page, with one low, wide shadow, rather than a bevelled block. The status pill no longer repeats a genre the select already shows. Also fixes a reference to renderAll's local activeGenre from updateScoresOnly. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- app.html | 6 ++--- app.js | 8 ++++-- scripts/browser-smoke.js | 9 +++++++ workspace-header.css | 55 ++++++++++++++++++++++------------------ workspace-header.js | 15 +++++++---- 5 files changed, 58 insertions(+), 35 deletions(-) diff --git a/app.html b/app.html index 25310d6..8b86c75 100644 --- a/app.html +++ b/app.html @@ -7,7 +7,7 @@ AuthorScrolls - Author's Best Buddy - + @@ -460,8 +460,8 @@

Choos - - + +