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..763c0af 100644 --- a/app.html +++ b/app.html @@ -5,9 +5,9 @@ AuthorScrolls - Author's Best Buddy - + - + @@ -439,7 +439,7 @@

Choos - + @@ -460,8 +460,8 @@

Choos - - + + - + diff --git a/app.js b/app.js index b6929ba..cc9cb92 100644 --- a/app.js +++ b/app.js @@ -317,7 +317,9 @@ function renderAll(){ _renderSubScores(r); // Topbar genre shows the user-overridden label (secondary cleared on override) if(activeGenre&&r.genre)r.genre.secondary=null; - $('top-status').textContent=(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 '+(r.manuscriptMode?.label||''); + // The genre select beside this already shows the chosen genre; repeat it only when the + // genre was detected rather than chosen, since the select then reads "Auto-detect". + $('top-status').textContent=(activeGenre?'':(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 ')+(r.manuscriptMode?.label||''); renderSceneIntel(r); // Book preview now renders on-demand when the Preview tab is activated — not here @@ -865,7 +867,10 @@ function updateScoresOnly(r){ _renderSubScores(r); // Topbar $('top-wc').textContent=(r.totalWords||0).toLocaleString(); - $('top-status').textContent=(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 '+(r.manuscriptMode?.label||''); + // Same rule as renderAll: the select already shows a chosen genre, so only a detected one is + // repeated here. (activeGenre is renderAll's local; it is not in scope in this function.) + const chosenGenre=($('genre-override')&&$('genre-override').value)||($('genre-select')&&$('genre-select').value)||''; + $('top-status').textContent=(chosenGenre?'':(r.genre?.label||'Unknown')+(r.genre?.secondary?' / '+r.genre.secondary:'')+' \u00B7 ')+(r.manuscriptMode?.label||''); drawGauge(r.overall||0); $('top-score').textContent=r.overall||0; @@ -1804,56 +1809,77 @@ function renderBlurbs(r){ const d=$('ed-blurbs');if(!d||!r)return;d.className='ms-page dark-page'; const b=r.blurbs; if(!b||!b.available){ - d.innerHTML='

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..8f164c0 --- /dev/null +++ b/blurbs.test.js @@ -0,0 +1,74 @@ +'use strict'; +// Back-cover material must be the author's sentences with a stated basis, never lines the +// engine wrote, and a nonfiction book must not be handed a protagonist and a crisis. +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'); +globalThis.ProseContext=require('./prose-context');globalThis.ProseNorms=require('./prose-norms'); +eval(fs.readFileSync(require.resolve('./analyzer'),'utf8')+'; globalThis.Analyzer=Analyzer;'); + +const INVENTED=/must face a choice|one chance to survive|lose everything|no turning back|Nothing will ever be the same|Some doors, once opened|The countdown has begun|has become a fight for/; + +function selfHelp(){ + const filler='Most people assume that effort is the whole story. Research generally suggests otherwise, and the pattern repeats in every city I have worked in. '; + let t='Introduction\n\nSit with me for a moment. If that’s you, take a seat. I wrote this for you.\n\nNow, perhaps you feel like you are doing everything right and still coming up short—carrying invisible weight, performing fine while struggling.\n\nWhen I arrived in the United States, I carried one bag and a pair of shoes.\n\n'; + for(let c=1;c<=8;c++){t+='Chapter '+c+': Practice '+c+'\n\n';for(let i=0;i<40;i++)t+=filler+'\n\n';t+='Action: Write down one belief you want to test this week.\n\nAccording to a 2019 study, small habits compound.\n\n';} + return t; +} +// Paragraphed like a book (the framework slices by paragraph), with a short first chapter so +// the inciting line at the top of chapter 2 falls inside the first quarter. +function novel(){ + const para=(s,n)=>{let out='';for(let i=0;i{ + const r=Analyzer.analyze(selfHelp(),'selfHelp'); + const b=r.blurbs; + assert.equal(b.available,true); + assert.equal(b.kind,'material'); + assert.ok(!('protagonist' in b),'no protagonist is invented for a self-help book'); + assert.ok(!JSON.stringify(b).match(INVENTED),'no fiction filler anywhere'); + assert.ok(b.material.promise.rows.some(row=>/I wrote this for you/.test(row.text)),JSON.stringify(b.material.promise)); + assert.ok(b.material.problem.rows.some(row=>/coming up short/.test(row.text))); + assert.ok(b.material.authorStake.rows.some(row=>/When I arrived/.test(row.text))); + assert.ok(b.material.promise.rows.every(row=>typeof row.section==='string'),'every row says where it came from'); +}); + +test('fiction answers carry the basis they were chosen on, and missing beats are gaps, not filler',()=>{ + const r=Analyzer.analyze(novel(),'literary'); + const b=r.blurbs; + assert.equal(b.kind,'framework'); + assert.equal(b.protagonist,'Mara'); + for(const key of ['statusQuo','incitingIncident','conflict','attempt','crisis','stakes']){ + assert.ok(b.framework[key].basis.length>20,key+' states its basis'); + } + assert.match(b.framework.statusQuo.basis,/wants, needs or hopes/); + assert.match(b.framework.incitingIncident.basis,/containing “(arrived|one day)”/i); + for(const bl of b.blurbs){ + assert.ok(!INVENTED.test(bl.text),bl.style+' contains an invented line: '+bl.text); + assert.ok(Number.isFinite(bl.gaps)); + } + // The three shapes that used to end on a stock closer now end on a gap the author writes; + // the other two may end on a manuscript sentence when every beat was found. + for(const style of ['Question Hook','Stakes Forward','Cinematic']){ + const bl=b.blurbs.find(x=>x.style===style); + assert.ok(bl.gaps>=1,style+' leaves its closing line to the author'); + assert.match(bl.text,/\[[^\]]+\]\s*$/,style+' ends on a gap, not an invented line'); + } +}); + +test('tone is a rate per thousand words, and a handful of dark words in a long book is not "dark"',()=>{ + let t='Chapter 1\n\n';for(let i=0;i<1500;i++)t+='She walked to the market and bought bread, then carried it home along the river. '; + t+='The fear passed. The dark came early. Fear again, and the dark, and a shadow, and one more fear.'; + const b=Analyzer.analyze(t,'literary').blurbs; + assert.ok(b.toneRates&&Number.isFinite(b.toneRates.dark)); + assert.ok(b.toneRates.dark<1,'six words in twenty thousand: '+b.toneRates.dark); + assert.equal(b.toneDetected,'no dominant tone vocabulary'); +}); 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); } }); } diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js index fa56509..713cc78 100644 --- a/scripts/browser-smoke.js +++ b/scripts/browser-smoke.js @@ -113,6 +113,15 @@ function pdf(){ await page.keyboard.press('Control+z'); assert.equal(providerCalls,0,'No automatic paid calls on manuscript open'); await page.screenshot({path:path.join(root,'test-results/editor-desktop.png')}); + // Header: one row, the title leads, the meta line sits under it, and the header is a plane + // over the page (a shadow), not a second bar. + assert.equal(await page.locator('.author-metadata').count(),0,'the second header row is gone'); + assert.ok(await page.locator('.author-document .document-meta #top-wc').count()===1,'word count sits under the title'); + const headerStyle=await page.locator('.author-header').evaluate(el=>{const s=getComputedStyle(el);return {shadow:s.boxShadow,title:parseFloat(getComputedStyle(el.querySelector('#top-filename')).fontSize)}}); + assert.ok(headerStyle.shadow!=='none','the header casts a shadow onto the page'); + assert.ok(headerStyle.title>=18,'the title leads: '+headerStyle.title+'px'); + assert.ok(!/Fantasy · Fantasy/.test(await page.locator('.author-header').innerText()),'the genre is not said twice'); + console.log('PASS header: one row, title leads, layered depth, genre said once'); // Inline suggestions: a list synonym is shown to weigh, never applied for the author, // and a finding set aside for its passage is drawn quietly with its reason. const weakVerb=page.locator('.hl[data-t="weak-verb"]:not(.hl-ctx)').first(); @@ -263,6 +272,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} diff --git a/workspace-header.css b/workspace-header.css index f442258..f4f506d 100644 --- a/workspace-header.css +++ b/workspace-header.css @@ -1,38 +1,43 @@ -/* Authorial, quiet chrome: document identity first, frequent actions second. */ +/* Authorial, quiet chrome: the manuscript's title leads, frequent actions second, menus last. + The header is a plane over the page (one low, wide shadow), not a bevelled block. */ .legacy-recovery{max-width:520px;width:calc(100% - 48px);padding:28px;background:#f6efe2;color:#302719;border:1px solid #b09a78;border-radius:10px;box-sizing:border-box;font:14px/1.6 Inter,sans-serif} .legacy-recovery::backdrop{background:#17130dcc}.legacy-recovery h2{font:24px Lora,Georgia,serif}.legacy-recovery-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}.legacy-recovery button{padding:12px;border:1px solid #877155;border-radius:5px;background:#ede1cc;color:#302719;cursor:pointer}.legacy-recovery button:focus-visible{outline:3px solid #91672f;outline-offset:3px} -.author-header{--header-ink:#e9dfd1;--header-muted:#aaa093;display:grid;grid-template-columns:auto minmax(120px,1fr) auto;grid-template-rows:68px 35px;column-gap:28px;padding:0 24px;height:auto;background:#201e1a;color:var(--header-ink);border-bottom:1px solid #504438;position:relative;z-index:45;font-family:Inter,sans-serif} -.author-brand,.author-brand>a,.author-actions,.author-metadata,.manuscript-context,.manuscript-metrics{display:flex;align-items:center} -.author-brand{gap:16px}.author-brand>a{gap:9px;color:inherit;text-decoration:none} -.author-brand .brand{font-family:Lora,Georgia,serif;font-size:17px;letter-spacing:-.4px} -.header-divider{width:1px;height:23px;background:#504438} -.author-header .tb-btn,.header-menu summary{font:500 12px Inter,sans-serif;line-height:1.2;min-height:40px;padding:10px 13px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--header-ink);white-space:nowrap;cursor:pointer;text-decoration:none;transition:background .15s,border-color .15s} -.author-header .tb-btn:hover,.header-menu summary:hover{background:#39332b;border-color:#5c4d3e} -.author-document{min-width:0;align-self:center}.document-eyebrow{font-size:9px;letter-spacing:1.5px;color:var(--header-muted);display:block;margin-bottom:5px} -.author-header #top-filename{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Lora,Georgia,serif;font-size:15px;font-weight:400;color:var(--header-ink)} -.author-actions{gap:6px;justify-self:end}.author-header #save-btn{background:#d1ae80;color:#241d14;border-color:#d1ae80;font-weight:600;min-width:62px}.author-header #save-btn:hover{background:#e1c39e} -.author-header #intel-open{border-color:#6a5945}.author-header #new-btn{color:var(--header-muted);padding-left:0;padding-right:0} +.author-header{--header-ink:#e9dfd1;--header-muted:#aaa093;--header-rule:#504438;--header-shadow:rgba(0,0,0,.38);display:grid;grid-template-columns:auto minmax(120px,1fr) auto;align-items:center;column-gap:24px;padding:0 24px;min-height:76px;height:auto;background:#201e1a;color:var(--header-ink);border-bottom:0;box-shadow:0 1px 0 var(--header-rule),0 10px 28px var(--header-shadow);position:relative;z-index:45;font-family:Inter,sans-serif} +.author-brand,.author-brand>a,.author-actions,.manuscript-context,.manuscript-metrics,.document-meta{display:flex;align-items:center} +.author-brand{gap:10px}.author-brand>a{color:inherit;text-decoration:none;line-height:0} +.author-header #new-btn{color:var(--header-muted);padding:8px 4px;font-size:12px} +.author-header .tb-btn,.header-menu summary{font:500 12px Inter,sans-serif;line-height:1.2;min-height:40px;padding:10px 13px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--header-ink);white-space:nowrap;cursor:pointer;text-decoration:none;transition:background .15s,border-color .15s,color .15s} +.author-header .tb-btn:hover{background:#39332b;border-color:#5c4d3e} +/* The document: eyebrow, title, then the meta line that used to be a whole second row. */ +.author-document{min-width:0;align-self:center;padding:10px 0} +.document-eyebrow{font-size:9px;letter-spacing:1.5px;color:var(--header-muted);display:block;margin-bottom:3px} +.author-header #top-filename{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Lora,Georgia,serif;font-size:20px;line-height:1.15;font-weight:500;letter-spacing:-.2px;color:var(--header-ink)} +.document-meta{gap:8px;margin-top:4px;font-size:10.5px;color:var(--header-muted);min-width:0;flex-wrap:wrap} +.manuscript-context,.manuscript-metrics{gap:8px;min-width:0}.manuscript-context label{white-space:nowrap} +.author-header .genre-override{width:auto;max-width:150px;min-width:70px;padding:2px 18px 2px 0;background:transparent;border:0;color:var(--header-ink);font:600 10.5px Inter,sans-serif;cursor:pointer} +.author-header .genre-override option,.author-header .genre-override optgroup{background:#29251f;color:#e9dfd1} +.author-header .status-pill{background:transparent;border:0;padding:0;color:var(--header-muted);font-size:10.5px} +.metadata-dot{color:#776655}.manuscript-metrics b{font-weight:600;color:var(--header-ink)}.health-indicator{white-space:nowrap} +/* Actions: one filled, one outlined, menus as text. */ +.author-actions{gap:6px;justify-self:end} +.author-header #save-btn{background:#d1ae80;color:#241d14;border-color:#d1ae80;font-weight:600;min-width:62px}.author-header #save-btn:hover{background:#e1c39e} +.author-header #intel-open{border-color:#6a5945} #save-state{font-size:10px;color:var(--header-muted);max-width:100px;text-align:right;line-height:1.4;margin-right:8px} #save-state[data-state="error"],#save-state[data-state="dirty"]{color:#e7bd86} -.header-menu{position:relative}.header-menu summary{list-style:none;display:flex;gap:8px;align-items:center}.header-menu summary::-webkit-details-marker{display:none} -.header-menu[open]>summary{background:#39332b;border-color:#6a5945} +.header-menu{position:relative}.header-menu summary{list-style:none;display:flex;gap:6px;align-items:center;color:var(--header-muted);padding:10px 8px}.header-menu summary::-webkit-details-marker{display:none} +.header-menu summary:hover{color:var(--header-ink);background:transparent} +.header-menu[open]>summary{color:var(--header-ink);background:#39332b;border-color:#6a5945} .header-menu-panel{position:absolute;right:0;top:calc(100% + 8px);width:248px;background:#29251f;border:1px solid #63513e;border-radius:9px;padding:8px;box-shadow:0 16px 40px #0006;z-index:60} .menu-label{display:block;padding:9px 10px;color:#b7a48e;font-size:9px;letter-spacing:1.5px} .header-menu-panel a,.header-menu-panel .tb-btn{display:flex;width:100%;align-items:center;text-align:left;padding:12px 10px;font-size:12px;min-height:44px;color:#e9dfd1;text-decoration:none;box-sizing:border-box;border-radius:5px} .header-menu-panel a:hover{background:#39332b}.header-menu-panel #signout-btn{border-top:1px solid #514436;border-radius:0;margin-top:5px} -.author-metadata{grid-column:1/-1;justify-content:space-between;border-top:1px solid #363028;gap:20px;font-size:10px;color:var(--header-muted)} -.manuscript-context,.manuscript-metrics{gap:12px;min-width:0}.manuscript-context label{white-space:nowrap} -.author-header .genre-override{width:auto;max-width:150px;min-width:70px;padding:3px 20px 3px 0;background:transparent;border:0;color:var(--header-ink);font:500 10px Inter,sans-serif} -.author-header .genre-override option,.author-header .genre-override optgroup{background:#29251f;color:#e9dfd1} -.author-header .status-pill{background:transparent;border:0;padding:0;color:var(--header-muted);font-size:10px} -.metadata-dot{color:#776655}.manuscript-metrics b{font-weight:600;color:var(--header-ink)}.health-indicator{white-space:nowrap} .author-header :is(button,a,summary,select):focus-visible{outline:2px solid #e8bd81;outline-offset:3px} -[data-editor-theme="light"] .author-header{--header-ink:#33291f;--header-muted:#746655;background:#f0e8db;border-color:#d2c3af} -[data-editor-theme="light"] .author-metadata{border-color:#d2c3af} -[data-editor-theme="light"] .author-header :is(.tb-btn,summary):hover{background:#e4d7c4} +[data-editor-theme="light"] .author-header{--header-ink:#33291f;--header-muted:#746655;--header-rule:#d2c3af;--header-shadow:rgba(60,45,25,.18);background:#f0e8db} +[data-editor-theme="light"] .author-header .tb-btn:hover{background:#e4d7c4} +[data-editor-theme="light"] .header-menu[open]>summary{background:#e4d7c4} [data-editor-theme="light"] .header-menu-panel :is(.tb-btn,a){color:#e9dfd1} .center-toolbar{gap:8px;padding:10px 16px;min-height:54px;flex-wrap:wrap}.center-toolbar .fmt-btn{min-width:30px;min-height:32px}.center-toolbar .fmt-right{margin-left:auto} .ms-page.parchment::after{content:attr(data-pagination);display:block;text-align:center;font:11px Inter,sans-serif;color:#827563;margin-top:36px;pointer-events:none;user-select:none} -@media(max-width:1200px){.author-header{column-gap:16px;padding:0 18px}.author-brand .brand{display:none}#save-state{display:none}} -@media(max-width:800px){.author-header{grid-template-columns:auto minmax(0,1fr);grid-template-rows:56px 48px 34px;column-gap:20px;padding:0 14px}.author-actions{grid-column:1/-1;justify-self:stretch;justify-content:flex-end;border-top:1px solid #363028;padding-top:3px}.author-actions>[data-slot="save"]{margin-right:auto}.author-metadata{gap:6px}.author-header .status-pill,.manuscript-context .metadata-dot,.manuscript-metrics .metadata-dot,.health-indicator{display:none}.manuscript-context,.manuscript-metrics{gap:6px}.author-header #top-filename{font-size:13px}.author-brand{gap:9px}.author-header .tb-btn,.header-menu summary{min-height:42px;padding:10px;font-size:11px}.author-brand #new-btn{font-size:11px}.document-eyebrow{font-size:8px}.author-header .genre-override{max-width:105px}.center-toolbar{padding:8px;gap:4px}.center-toolbar .fmt-right{margin-left:0;justify-content:flex-start}.header-menu-panel{position:fixed;top:auto;right:14px;margin-top:8px;max-width:calc(100vw - 28px)}} +@media(max-width:1200px){.author-header{column-gap:16px;padding:0 18px}#save-state{display:none}.author-header #top-filename{font-size:18px}} +@media(max-width:800px){.author-header{grid-template-columns:auto minmax(0,1fr);grid-template-rows:auto auto;column-gap:14px;padding:0 14px;min-height:0}.author-document{padding:8px 0}.author-actions{grid-column:1/-1;justify-self:stretch;justify-content:flex-end;box-shadow:0 -1px 0 #363028;padding:4px 0}.author-actions>[data-slot="save"]{margin-right:auto}.document-meta{gap:5px}.author-header .status-pill,.health-indicator{display:none}.author-header #top-filename{font-size:15px}.author-header .tb-btn,.header-menu summary{min-height:42px;padding:10px;font-size:11px}.author-header #new-btn{font-size:11px}.document-eyebrow{font-size:8px}.author-header .genre-override{max-width:105px}.center-toolbar{padding:8px;gap:4px}.center-toolbar .fmt-right{margin-left:0;justify-content:flex-start}.header-menu-panel{position:fixed;top:auto;right:14px;margin-top:8px;max-width:calc(100vw - 28px)}} @media(prefers-reduced-motion:reduce){.author-header *{transition:none!important}} diff --git a/workspace-header.js b/workspace-header.js index 2d163be..267daaf 100644 --- a/workspace-header.js +++ b/workspace-header.js @@ -7,9 +7,15 @@ 'intel-open','save-btn','new-btn','export-btn','optional-checks-btn','signout-btn']; const controls = Object.fromEntries(ids.map(id => [id, document.getElementById(id)])); header.classList.add('author-header'); + // One row: the manuscript's title leads; the reading context and the counts sit under it as + // a meta line; the mark is the brand and Library is a quiet link beside it; one filled + // action, one outlined, and the two menus as text. Depth comes from the header being a + // plane over the page (one shadow), not from bevels. header.innerHTML = ` - -
YOUR MANUSCRIPT
+
+
YOUR MANUSCRIPT
+
wordsEditing signals
+
- `; + `; const place = (slot,id,label) => { const el=controls[id];if(!el)return; if(label)el.textContent=label; @@ -29,7 +34,7 @@ place('library','new-btn','Library'); place('title','top-filename');place('status','top-status');place('genre','genre-override'); place('words','top-wc');place('score','top-score');place('delta','top-delta'); - place('save','save-btn','Save');place('intelligence','intel-open','Intelligence'); + place('save','save-btn','Save');place('intelligence','intel-open','✦ Intelligence'); for (const [id,label] of [['export-btn','Export manuscript'],['optional-checks-btn','Optional AI & grammar checks']]) { if(controls[id]){controls[id].textContent=label;document.getElementById('document-actions').append(controls[id]);} }