From ddd2abbf8904ec92be3e6bba18a064eb1d9d472d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:56:07 +0000 Subject: [PATCH 1/2] AI analysis runs once per 24 hours per user, enforced at the API Whole-manuscript AI analysis (deep critique, chapter breakdown, opening and weakness analysis, comp titles, query letter, beta readers, market readiness, editing roadmap, reader simulation, smart scan, issue calibration) is limited to one run per user per 24 hours. Ask Book, rewrites and fixes are per-request features and keep the existing daily counts. The dev tier is exempt so the feature can be exercised. Server (the only enforcement): a run is the burst of analysis requests an author fires together, so requests are allowed for fifteen minutes after the run opens; after that the endpoint answers 429 ANALYSIS_COOLDOWN with nextAt and Retry-After until 24 hours from the opening. The run opens only after a provider request succeeds, so a failed call does not spend it. Redis key with a 24-hour expiry; the in-memory fallback keeps the same rules and production still fails closed without Redis. Checked before the daily count so a refused run is not charged. Client: the refusal becomes a sentence with the next time ("The next run is available today at 2:05 PM"), the browser remembers that time and does not spend another request until then, cached results stay open, and the AI panel says which features are unaffected. Tests: the window's open/grace/closed/expired states, the 429 with nextAt before any provider contact, opening only on success, per-request features and dev never consulting the window, and the browser flow through the refusal and the remembered time. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- ai-cooldown.test.js | 81 ++++++++++++++++++++++++++++++++++++++++ ai-engine.js | 23 ++++++++++++ api/_ratelimit.js | 35 ++++++++++++++++- api/claude.js | 50 ++++++++++++++++++------- app.html | 4 +- app.js | 8 +++- scripts/browser-smoke.js | 17 +++++++++ 7 files changed, 200 insertions(+), 18 deletions(-) create mode 100644 ai-cooldown.test.js diff --git a/ai-cooldown.test.js b/ai-cooldown.test.js new file mode 100644 index 0000000..ac4aacd --- /dev/null +++ b/ai-cooldown.test.js @@ -0,0 +1,81 @@ +'use strict'; +// AI analysis runs once per 24 hours per user, enforced at the API. A run is the burst of +// analysis requests fired together; it opens on the first successful response and closes +// fifteen minutes later; the next run is 24 hours after it opened. Per-request features +// (Ask Book, rewrite) are untouched, and the dev tier is exempt. +const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'); +const rl=require('./api/_ratelimit'); + +test('the analysis window: open on success, a grace period for the burst, then closed until 24 hours',async()=>{ + const t0=Date.parse('2026-09-22T10:00:00Z'); + const uid='window-'+Math.random(); + assert.deepEqual(await rl.analysisRun(uid,t0),{allowed:true,startedAt:null,nextAt:null}); + await rl.openAnalysisRun(uid,t0); + const inBurst=await rl.analysisRun(uid,t0+10*60*1000); + assert.equal(inBurst.allowed,true,'the rest of the burst goes through'); + assert.equal(inBurst.startedAt,t0); + const later=await rl.analysisRun(uid,t0+20*60*1000); + assert.equal(later.allowed,false); + assert.equal(later.nextAt,t0+rl.ANALYSIS_PERIOD_MS); + await rl.openAnalysisRun(uid,t0+20*60*1000); + assert.equal((await rl.analysisRun(uid,t0+21*60*1000)).startedAt,t0,'a second open inside the period does not move the clock'); + assert.equal((await rl.analysisRun(uid,t0+rl.ANALYSIS_PERIOD_MS+1)).allowed,true,'the day is over'); +}); + +function proxy({feature,tier='premium',run,env={OPENAI_API_KEY:'k'},provider=200}={}){ + const calls={analysisRun:0,open:0,provider:0}; + const db={collection:name=>({doc:uid=>({path:name+'/'+uid})}),getAll:async()=>[{exists:true,data:()=>({tier})},{exists:false}]}; + const context={module:{exports:{}},process:{env},console,Buffer,Set,Date,Number,Math,JSON, + require:id=>{ + if(id==='https'){ + return {request(options,onResponse){ + calls.provider++; + const listeners={}; + const res={statusCode:provider,on(ev,fn){listeners[ev]=fn;}}; + return {on(){},setTimeout(){},write(){},end(){setImmediate(()=>{onResponse(res);listeners.data('{"choices":[{"message":{"content":"{}"}}]}');listeners.end();});}}; + }}; + } + if(id==='./_auth')return {verifyToken:async()=>({user:{uid:'author'}}),getAdmin:()=>({firestore:()=>db})}; + if(id==='./_ratelimit')return {checkAndIncrement:async()=>1,analysisRun:async()=>{calls.analysisRun++;return run||{allowed:true,startedAt:null,nextAt:null};},openAnalysisRun:async()=>{calls.open++;}}; + return require(id); + }}; + vm.runInNewContext(fs.readFileSync(require.resolve('./api/claude'),'utf8'),context); + const handler=context.module.exports; + return {calls,async call(){ + const res={statusCode:200,headers:{},status(c){this.statusCode=c;return this;},json(b){this.body=b;return this;},setHeader(k,v){this.headers[k]=v;}}; + await handler({method:'POST',headers:{'x-model':'openai-fast','x-feature':feature},body:{messages:[{role:'user',content:'Synthetic'}]}},res); + return res; + }}; +} + +test('a second analysis run inside 24 hours is refused with the next time, before the daily count',async()=>{ + const nextAt=Date.now()+3600000; + const p=proxy({feature:'deepCritique',run:{allowed:false,startedAt:nextAt-86400000,nextAt}}); + const res=await p.call(); + assert.equal(res.statusCode,429); + assert.equal(res.body.error.code,'ANALYSIS_COOLDOWN'); + assert.equal(res.body.error.nextAt,nextAt); + assert.match(res.body.error.message,/once every 24 hours/); + assert.ok(Number(res.headers['Retry-After'])>0); + assert.equal(p.calls.provider,0,'nothing reached a provider'); +}); + +test('the run opens only after a successful provider response',async()=>{ + const ok=proxy({feature:'editingRoadmap'}); + await ok.call(); + assert.equal(ok.calls.analysisRun,1); + assert.equal(ok.calls.open,1,'a successful analysis opens the window'); + const failed=proxy({feature:'editingRoadmap',provider:500}); + await failed.call(); + assert.equal(failed.calls.open,0,'a failed provider call does not spend the run'); +}); + +test('per-request features and the dev tier never consult the analysis window',async()=>{ + const ask=proxy({feature:'ask-manuscript',run:{allowed:false,startedAt:1,nextAt:Date.now()+1000}}); + const res=await ask.call(); + assert.equal(res.statusCode,200); + assert.equal(ask.calls.analysisRun,0); + const dev=proxy({feature:'deepCritique',run:{allowed:false,startedAt:1,nextAt:Date.now()+1000},env:{OPENAI_API_KEY:'k',ADMIN_UIDS:'author'}}); + assert.equal((await dev.call()).statusCode,200); + assert.equal(dev.calls.analysisRun,0); +}); diff --git a/ai-engine.js b/ai-engine.js index 84f09f7..d95cc38 100644 --- a/ai-engine.js +++ b/ai-engine.js @@ -42,6 +42,18 @@ const AIEngine = { // Local dev: /api/claude (Express server) // Both work with the same path API_ENDPOINT: '/api/claude', + // Whole-manuscript analysis features run once per 24 hours; the server enforces it and this + // browser remembers the next run time it was told, so the author sees the time, not a spent request. + ANALYSIS_FEATURES: new Set(['deepCritique','chapterBreakdown','openingAnalysis','weaknessAnalysis','compTitles','queryLetter','betaReaders','marketReadiness','editingRoadmap','readerSimulation','smartScan','calibrateIssues']), + nextAnalysisRun() { + try { const next = Number(localStorage.getItem('ml_ai_next_run')); if (next && next > Date.now()) return next; if (next) localStorage.removeItem('ml_ai_next_run'); } catch (_) {} + return null; + }, + cooldownMessage(nextAt) { + const when = new Date(nextAt); + const sameDay = when.toDateString() === new Date().toDateString(); + return 'AI analysis runs once every 24 hours. The next run is available ' + (sameDay ? 'today at ' + when.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : when.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })) + '.'; + }, // Model routing: cheap OpenAI for most, Claude for premium _routeModel(feature) { @@ -146,6 +158,13 @@ const AIEngine = { ] }); + // AI analysis runs once per 24 hours (server-enforced). When the server has told this browser + // the next run time, say so without spending a request; cached results above still show. + if (AIEngine.ANALYSIS_FEATURES.has(headers['x-feature'])) { + const next = AIEngine.nextAnalysisRun(); + if (next) throw new Error(AIEngine.cooldownMessage(next)); + } + let response = await fetch(endpoint, { method: 'POST', headers, body: bodyPayload }); if (response.status === 401) { @@ -161,6 +180,10 @@ const AIEngine = { if (!response.ok) { const err = await response.json().catch(() => ({})); console.error('[AI API error]', response.status, err, 'User:', currentUser?.email || 'none', 'Had token:', !!headers['authorization']); + if (err.error?.code === 'ANALYSIS_COOLDOWN' && Number.isFinite(Number(err.error.nextAt))) { + try { localStorage.setItem('ml_ai_next_run', String(Number(err.error.nextAt))); } catch (_) {} + throw new Error(AIEngine.cooldownMessage(Number(err.error.nextAt))); + } if (err.error?.code === 'UNAUTHENTICATED') { const reason = err.error?.reason || 'unknown'; const diag = err.error?.diagnostic || ''; diff --git a/api/_ratelimit.js b/api/_ratelimit.js index 8086ca2..e2e6477 100644 --- a/api/_ratelimit.js +++ b/api/_ratelimit.js @@ -60,4 +60,37 @@ async function getCount(userId, today) { return fallbackMap.get(key) || 0; } -module.exports = { checkAndIncrement, getCount }; +// AI analysis runs once per 24 hours per user. A "run" is the burst of analysis requests an +// author fires together (deep critique, roadmap, opening…), so requests are allowed for a +// grace window after the run opens; after that the next run is 24 hours from the opening. +// The run opens only after a provider request succeeds, so a failed call does not spend it. +const ANALYSIS_PERIOD_MS = 24 * 60 * 60 * 1000; +const ANALYSIS_GRACE_MS = 15 * 60 * 1000; +const runFallback = new Map(); + +async function analysisRun(userId, nowMs = Date.now()) { + const key = 'ai:run:' + userId; + const r = getRedis(); + if(!r&&(process.env.VERCEL||process.env.NODE_ENV==='production'))throw new Error('Persistent rate limiter unavailable'); + let startedAt = null; + if (r) { const raw = await r.get(key); startedAt = raw == null ? null : Number(raw); } + else { + const entry = runFallback.get(key); + if (entry && entry.expiresAt > nowMs) startedAt = entry.startedAt; else runFallback.delete(key); + } + if (startedAt == null || !Number.isFinite(startedAt)) return { allowed: true, startedAt: null, nextAt: null }; + const nextAt = startedAt + ANALYSIS_PERIOD_MS; + if (nowMs >= nextAt) return { allowed: true, startedAt: null, nextAt: null }; + return { allowed: nowMs - startedAt <= ANALYSIS_GRACE_MS, startedAt, nextAt }; +} + +async function openAnalysisRun(userId, nowMs = Date.now()) { + const key = 'ai:run:' + userId; + const r = getRedis(); + if(!r&&(process.env.VERCEL||process.env.NODE_ENV==='production'))throw new Error('Persistent rate limiter unavailable'); + if (r) { await r.set(key, String(nowMs), { nx: true, ex: Math.ceil(ANALYSIS_PERIOD_MS / 1000) }); return; } + const entry = runFallback.get(key); + if (!entry || entry.expiresAt <= nowMs) runFallback.set(key, { startedAt: nowMs, expiresAt: nowMs + ANALYSIS_PERIOD_MS }); +} + +module.exports = { checkAndIncrement, getCount, analysisRun, openAnalysisRun, ANALYSIS_PERIOD_MS, ANALYSIS_GRACE_MS }; diff --git a/api/claude.js b/api/claude.js index 18e1ad1..2368d00 100644 --- a/api/claude.js +++ b/api/claude.js @@ -3,9 +3,12 @@ // Rate limited per user per day const https = require('https'); const { verifyToken, getAdmin } = require('./_auth'); -const { checkAndIncrement } = require('./_ratelimit'); +const { checkAndIncrement, analysisRun, openAnalysisRun } = require('./_ratelimit'); const LIMITS = { dev: 9999, beta: 50, premium: 75, starter: 25, free: 0 }; +// Whole-manuscript AI analysis runs once per 24 hours (see _ratelimit.analysisRun). Ask Book, +// rewrites and fixes are per-request features and stay on the daily count above. +const ANALYSIS_FEATURES = new Set(['deepcritique','chapterbreakdown','openinganalysis','weaknessanalysis','comptitles','queryletter','betareaders','marketreadiness','editingroadmap','readersimulation','smartscan','calibrateissues']); async function getUserTier(userId) { const fb = getAdmin(); @@ -124,6 +127,23 @@ module.exports = async (req, res) => { // Count only authenticated, authorized, structurally valid requests. const needsClaude=requestedModel==='claude'||requestedModel==='claude-premium'; if(!(needsClaude?process.env.CLAUDE_API_KEY:process.env.OPENAI_API_KEY))return res.status(503).json({error:{message:'Requested provider is unavailable'}}); + // One AI analysis run per 24 hours. Checked before the daily count so a blocked run is + // not charged against it; the dev tier is exempt so the feature can be exercised. + const isAnalysis = ANALYSIS_FEATURES.has(requestedFeature) && userTier !== 'dev'; + let run = null; + if (isAnalysis) { + try { run = await analysisRun(userId, Date.now()); } + catch (_) { return res.status(503).json({ error: { message: 'Usage verification unavailable. Please try again.' } }); } + if (!run.allowed) { + res.setHeader('Retry-After', String(Math.max(1, Math.ceil((run.nextAt - Date.now()) / 1000)))); + res.status(429).json({ + error: { + message: 'AI analysis runs once every 24 hours. The next run is available at ' + new Date(run.nextAt).toISOString() + '.', + code: 'ANALYSIS_COOLDOWN', startedAt: run.startedAt, nextAt: run.nextAt + } + }); return; + } + } let used; try{used=await checkAndIncrement(userId,today);} catch(_){return res.status(503).json({error:{message:'Usage verification unavailable. Please try again.'}});} @@ -136,16 +156,18 @@ module.exports = async (req, res) => { }); return; } - if (requestedModel === 'claude' || requestedModel === 'claude-premium') { - return callClaude(sanitized, res); - } else { - return callOpenAI(sanitized, requestedModel, res); + const status = (requestedModel === 'claude' || requestedModel === 'claude-premium') + ? await callClaude(sanitized, res) + : await callOpenAI(sanitized, requestedModel, res); + // The run opens on the first successful analysis response, never on a failed one. + if (isAnalysis && run && run.startedAt == null && status >= 200 && status < 300) { + try { await openAnalysisRun(userId, Date.now()); } catch (_) { /* the response has been sent; nothing to add */ } } }; function callClaude(body, res) { const apiKey = process.env.CLAUDE_API_KEY; - if (!apiKey) { res.status(500).json({ error: { message: 'Claude API key not configured' } }); return Promise.resolve(); } + if (!apiKey) { res.status(500).json({ error: { message: 'Claude API key not configured' } }); return Promise.resolve(500); } const payload = JSON.stringify({ model: 'claude-sonnet-4-20250514', @@ -168,12 +190,13 @@ function callClaude(body, res) { let data = ''; proxyRes.on('data', chunk => data += chunk); proxyRes.on('end', () => { + let status = proxyRes.statusCode; try { res.status(proxyRes.statusCode).json(JSON.parse(data)); } - catch (e) { res.status(500).json({ error: { message: 'Invalid response from Claude' } }); } - resolve(); + catch (e) { status = 500; res.status(500).json({ error: { message: 'Invalid response from Claude' } }); } + resolve(status); }); }); - proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(); }); + proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(500); }); proxyReq.setTimeout(30000,()=>proxyReq.destroy(new Error('Provider request timed out'))); proxyReq.write(payload); proxyReq.end(); }); @@ -183,7 +206,7 @@ function callOpenAI(body, model, res) { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { res.status(503).json({error:{message:'Requested provider is unavailable'}}); - return Promise.resolve(); + return Promise.resolve(503); } // Map our model names to OpenAI models @@ -225,6 +248,7 @@ function callOpenAI(body, model, res) { let data = ''; proxyRes.on('data', chunk => data += chunk); proxyRes.on('end', () => { + let status = proxyRes.statusCode; try { const parsed = JSON.parse(data); if (parsed.error) { @@ -241,11 +265,11 @@ function callOpenAI(body, model, res) { _provider: 'openai', _model: openaiModel }); } - } catch (e) { res.status(500).json({ error: { message: 'Invalid response from OpenAI' } }); } - resolve(); + } catch (e) { status = 500; res.status(500).json({ error: { message: 'Invalid response from OpenAI' } }); } + resolve(status); }); }); - proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(); }); + proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(500); }); proxyReq.setTimeout(30000,()=>proxyReq.destroy(new Error('Provider request timed out'))); proxyReq.write(payload); proxyReq.end(); }); diff --git a/app.html b/app.html index ab2ad1a..64c5daf 100644 --- a/app.html +++ b/app.html @@ -457,13 +457,13 @@

Choos - + - + diff --git a/app.js b/app.js index 7039ac2..47801dc 100644 --- a/app.js +++ b/app.js @@ -2498,11 +2498,15 @@ async function runAI(key){ }catch(e){ st.classList.add('hidden'); const msg=e.message||'Unknown error'; - const isRateLimit=msg.includes('limit reached')||msg.includes('RATE_LIMITED'); + const isCooldown=msg.includes('once every 24 hours'); + const isRateLimit=!isCooldown&&(msg.includes('limit reached')||msg.includes('RATE_LIMITED')); const isAuthErr=msg.startsWith('SERVER_AUTH_ERROR:'); const intro=document.querySelector('.ai-intro'); if(intro){ - if(isRateLimit){ + if(isCooldown){ + intro.innerHTML='
⏳

AI analysis runs once every 24 hours

'+esc(msg.replace(/^AI analysis runs once every 24 hours\.\s*/,''))+'

Your last results stay open. Ask Book, rewrites and fixes are not affected.

'; + intro.classList.remove('hidden'); + }else if(isRateLimit){ intro.innerHTML='
🔒

Daily Limit Reached

Free accounts get 3 AI analyses per day.

Resets at midnight UTC.

50 AI analyses/day + Claude deep critique

'; intro.querySelector('.ai-upgrade-btn')?.addEventListener('click',()=>$('pricing-modal')?.classList.remove('hidden')); }else if(isAuthErr){ diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js index 2b27a8f..590d71e 100644 --- a/scripts/browser-smoke.js +++ b/scripts/browser-smoke.js @@ -381,6 +381,23 @@ function pdf(){ await page.locator('#auth-dialog[open]').waitFor(); await page.keyboard.press('Escape'); console.log('PASS returning author: homepage keeps the session, sign-up link opens the workspace, sign out from the nav'); + // AI analysis runs once per 24 hours: the server's refusal becomes a time the author can read, + // and the browser does not spend another request until then. + { + await page.goto(base+'/app.html'); + await page.locator('#lib-loading.hidden').waitFor({state:'attached'}); + const nextAt=Date.now()+2*3600000;let cooldownHits=0; + await context.route('**/api/claude',route=>{cooldownHits++;route.fulfill({status:429,json:{error:{code:'ANALYSIS_COOLDOWN',message:'AI analysis runs once every 24 hours.',nextAt}}});}); + const first=await page.evaluate(()=>AIEngine._callClaude(null,'system','user','Some manuscript text for the cooldown check.','deepCritique').then(()=>'ok').catch(e=>e.message)); + assert.match(first,/once every 24 hours\. The next run is available/); + assert.equal(cooldownHits,1); + const second=await page.evaluate(()=>AIEngine._callClaude(null,'system','user','Different manuscript text for the cooldown check.','deepCritique').then(()=>'ok').catch(e=>e.message)); + assert.match(second,/once every 24 hours/); + assert.equal(cooldownHits,1,'the browser remembers the next run time instead of asking again'); + await context.unroute('**/api/claude'); + await page.evaluate(()=>localStorage.removeItem('ml_ai_next_run')); + console.log('PASS AI analysis cooldown: refusal carries the next run time, no repeat request'); + } // Workspace demo: a visitor opens the real workspace on a public-domain sample with no // account, no cloud, no API call, and nothing left in browser storage. let apiCalls=0;page.on('request',req=>{if(req.url().includes('/api/'))apiCalls++;}); From 6b0a8b6dfb0b19186332f20706dd51dd5696dac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 03:28:50 +0000 Subject: [PATCH 2/2] Features page: the product as it renders, no emoji, surfaces that move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old page was a grid of emoji cards and stat tiles. This one follows the approved iteration and says only what the product does. - Hero: the claim, two actions (free analysis, the workspace demo), and a product frame that is real output: Narrative Structure on The Secret Garden, chapters 1–8, with the chapter curve drawn from the engine's own numbers, "described, not scored", and one finding with its Inspect link. No act labels, because nothing measures them. - Core analysis in three columns, Structure / Craft / Mechanics, each listing features that exist: narrative structure with chapters to inspect, pacing heatmap, opening diagnosis, reader-momentum signals; dialogue, show versus tell, line and copy editing judged for the passage; fiction-aware grammar, optional LanguageTool, no hidden floors. - Book Intelligence: the nine paid features by name, an example excerpt and an advisory insight marked as interpretation that never changes a score. - Genres: the twenty the app offers, fiction and nonfiction told apart. - Motion: cards lift and warm toward the pointer, the product frame tilts and breathes, the curve draws itself in on arrival, chips rise on hover; all off under reduced motion. Header, footer, sign-in dialog and theme come from the homepage's shared styles and script, so the page is one page of the same site in light and dark. The smoke test opens the page and checks: no emoji, "described, not scored", no act labels, one curve, twenty genre chips, and a card that lifts on hover. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- features-init.js | 40 ++++ features.css | 134 +++++++++++ features.html | 468 +++++++++++++++++---------------------- scripts/browser-smoke.js | 20 ++ 4 files changed, 393 insertions(+), 269 deletions(-) create mode 100644 features-init.js create mode 100644 features.css diff --git a/features-init.js b/features-init.js new file mode 100644 index 0000000..b77e36c --- /dev/null +++ b/features-init.js @@ -0,0 +1,40 @@ +// Features page motion: reveal sections as they arrive, draw the curve once it is seen, let the +// product frame breathe and tilt under the pointer, and let each card's glow follow the mouse. +// Everything here is decorative; the page reads the same with scripting off or reduced motion. +(() => { + 'use strict'; + const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; + const reveals = [...document.querySelectorAll('.fx-reveal')]; + if (reduce || !('IntersectionObserver' in window)) reveals.forEach(el => el.classList.add('is-in')); + else { + const io = new IntersectionObserver(entries => { + entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('is-in'); io.unobserve(entry.target); } }); + }, { rootMargin: '0px 0px -10% 0px', threshold: 0.15 }); + reveals.forEach((el, i) => { el.style.transitionDelay = (i % 3) * 90 + 'ms'; io.observe(el); }); + } + if (reduce) return; + + // The frame tilts a few degrees toward the pointer and settles back when it leaves. + const frame = document.getElementById('fx-frame'); + const win = frame && frame.querySelector('.fx-window'); + if (win && matchMedia('(hover: hover)').matches) { + let raf = 0, rx = 0, ry = 0; + const paint = () => { raf = 0; win.style.transform = 'rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg) scale(1.01)'; }; + frame.addEventListener('pointermove', e => { + const r = win.getBoundingClientRect(); + const px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5; + rx = -py * 5; ry = px * 6; + if (!raf) raf = requestAnimationFrame(paint); + }); + frame.addEventListener('pointerleave', () => { if (raf) cancelAnimationFrame(raf); raf = 0; win.style.transform = ''; }); + } + + // Each card's warm glow follows the pointer. + document.querySelectorAll('.fx-card').forEach(card => { + card.addEventListener('pointermove', e => { + const r = card.getBoundingClientRect(); + card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%'); + card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100).toFixed(1) + '%'); + }); + }); +})(); diff --git a/features.css b/features.css new file mode 100644 index 0000000..445af72 --- /dev/null +++ b/features.css @@ -0,0 +1,134 @@ +/* Features page. Tokens come from landing.css (dark by default, light under [data-theme=light]). + Motion: cards lift and warm on hover, the product frame breathes and tilts under the pointer, + the curve draws itself in when it scrolls into view. All of it is off under reduced motion. */ +.fx{--ease:cubic-bezier(.2,.8,.2,1);--lift:0 28px 60px -32px rgba(0,0,0,.65);--glow:0 0 0 1px var(--gold)} +.fx-body main{overflow-x:clip} +.fx h1,.fx h2,.fx h3{font-family:var(--display);font-weight:600;line-height:1.08;color:var(--text);margin:0} +.fx h1{font-size:clamp(2.3rem,4.3vw,3.5rem);letter-spacing:-.01em;max-width:15ch} +.fx h1 em,.fx h2 em{font-style:italic;color:var(--gold)} +.fx h2{font-size:clamp(1.9rem,3.6vw,2.9rem)} +.fx h3{font-size:1.35rem} +.fx p{color:var(--muted);line-height:1.6} +.fx .eyebrow{display:block;margin-bottom:var(--space-4)} +.fx-lede{font-size:1.05rem;max-width:44ch;margin:var(--space-6) 0 var(--space-8)} + +/* Buttons on the dark surfaces: the homepage's outline style was drawn for its paper hero. */ +.fx .button{margin:0} +.fx .button.outline{background:transparent;color:var(--text);border:1px solid color-mix(in srgb,var(--gold) 60%,var(--line));box-shadow:none;transition:border-color .4s var(--ease),background .4s var(--ease),transform .4s var(--ease)} +.fx .button.outline:hover{border-color:var(--gold);background:color-mix(in srgb,var(--gold) 10%,transparent);transform:translateY(-2px)} + +/* Hero */ +.fx-hero{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:clamp(2rem,5vw,5rem);align-items:center;padding-top:clamp(1rem,4vw,3rem);padding-bottom:clamp(2rem,4vw,3rem)} +.fx-hero .hero-actions{display:flex;flex-wrap:wrap;justify-content:flex-start;gap:var(--space-3);margin:0} +.fx-hero .hero-note{margin-top:var(--space-4);font-size:var(--text-sm);text-align:left} + +/* The product frame: the workspace as it renders, built from real output */ +.fx-frame{margin:0;perspective:1400px} +.fx-window{background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:0 40px 90px -40px rgba(0,0,0,.8),0 0 0 1px rgba(0,0,0,.2);transform-style:preserve-3d;transition:transform .6s var(--ease),box-shadow .6s var(--ease);will-change:transform} +.fx-frame:hover .fx-window{box-shadow:0 60px 110px -40px rgba(0,0,0,.85),var(--glow)} +.fx-window-bar{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-4);padding:.7rem 1rem;border-bottom:1px solid var(--line);font-size:.72rem;color:var(--muted)} +.fx-window-title{font-family:var(--display);font-size:1.05rem;color:var(--text)} +.fx-window-body{display:grid;grid-template-columns:150px minmax(0,1fr)} +.fx-window-nav{display:flex;flex-direction:column;gap:.15rem;padding:.8rem .6rem;border-right:1px solid var(--line);font-size:.7rem;color:var(--muted)} +.fx-window-nav span{padding:.35rem .55rem;border-radius:6px} +.fx-window-nav .on{background:color-mix(in srgb,var(--gold) 14%,transparent);color:var(--text)} +.fx-panel{padding:1rem 1.1rem 1.1rem;min-width:0} +.fx-panel-head{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-3)} +.fx-panel-head h2{font-size:1.1rem} +.fx-panel-head span{font-size:.66rem;color:var(--muted)} +.fx-panel-lede{font-size:.74rem;margin:.35rem 0 .5rem} +.fx-curve{display:block;width:100%;height:auto} +.fx-grid{stroke:var(--line);stroke-width:1} +.fx-tick,.fx-axis text{fill:var(--muted);font-size:9px;font-family:var(--body)} +.fx-axis-label{letter-spacing:.08em;font-size:8px} +.fx-area{fill:var(--gold);opacity:.14} +.fx-line{fill:none;stroke:var(--gold);stroke-width:2;stroke-linejoin:round;stroke-linecap:round;stroke-dasharray:700;stroke-dashoffset:700} +.fx-dots circle{fill:var(--surface);stroke:var(--gold);stroke-width:1.5;opacity:0;transition:r .3s var(--ease)} +.fx-dots .peak{fill:var(--gold)} +.fx-dots circle:hover{r:6} +.fx-frame.is-in .fx-line{animation:fx-draw 1.6s var(--ease) forwards} +.fx-frame.is-in .fx-dots circle{animation:fx-appear .5s var(--ease) forwards} +.fx-frame.is-in .fx-dots circle:nth-child(1){animation-delay:.2s}.fx-frame.is-in .fx-dots circle:nth-child(2){animation-delay:.4s}.fx-frame.is-in .fx-dots circle:nth-child(3){animation-delay:.6s}.fx-frame.is-in .fx-dots circle:nth-child(4){animation-delay:.8s}.fx-frame.is-in .fx-dots circle:nth-child(5){animation-delay:1s}.fx-frame.is-in .fx-dots circle:nth-child(6){animation-delay:1.2s}.fx-frame.is-in .fx-dots circle:nth-child(7){animation-delay:1.4s}.fx-frame.is-in .fx-dots circle:nth-child(8){animation-delay:1.6s} +@keyframes fx-draw{to{stroke-dashoffset:0}} +@keyframes fx-appear{to{opacity:1}} +.fx-curve-label{font-size:.66rem;text-align:center;margin:.1rem 0 .7rem} +.fx-finding{display:flex;justify-content:space-between;align-items:center;gap:var(--space-4);background:color-mix(in srgb,var(--gold) 8%,transparent);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:10px;padding:.65rem .8rem} +.fx-finding h3{font-family:var(--body);font-weight:600;font-size:.78rem;line-height:1.35} +.fx-finding p{font-size:.7rem;margin:.15rem 0 0} +.fx-inspect{flex-shrink:0;font-size:.7rem;color:var(--gold);border:1px solid var(--line);border-radius:8px;padding:.35rem .6rem;text-decoration:none;transition:background .3s var(--ease),transform .3s var(--ease)} +.fx-inspect:hover{background:color-mix(in srgb,var(--gold) 16%,transparent);transform:translateX(2px)} +.fx-frame figcaption{margin-top:var(--space-3);font-size:var(--text-xs);color:var(--muted);text-align:center} + +/* Sections */ +.fx-section{padding:clamp(3rem,7vw,6rem) clamp(1.25rem,5vw,5rem)} +.fx-section-intro{max-width:72ch;margin:0 auto var(--space-12);text-align:center} +.fx-section-intro p{margin-top:var(--space-4)} +.fx-columns{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-6)} + +/* Cards: the breathing surface */ +.fx-card{position:relative;background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:var(--space-8);transition:transform .55s var(--ease),box-shadow .55s var(--ease),border-color .55s var(--ease)} +.fx-card::after{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(600px circle at var(--mx,50%) var(--my,0%),color-mix(in srgb,var(--gold) 12%,transparent),transparent 45%);opacity:0;transition:opacity .55s var(--ease);pointer-events:none} +.fx-card:hover{transform:translateY(-6px);box-shadow:var(--lift);border-color:color-mix(in srgb,var(--gold) 55%,var(--line))} +.fx-card:hover::after{opacity:1} +.fx-icon{width:28px;height:28px;fill:none;stroke:var(--gold);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;margin-bottom:var(--space-4);transition:transform .55s var(--ease)} +.fx-card:hover .fx-icon{transform:translateY(-2px) scale(1.06)} +.fx-kicker{display:block;font-size:var(--text-xs);letter-spacing:.14em;text-transform:uppercase;color:var(--gold);margin-bottom:var(--space-3)} +.fx-card h3{margin-bottom:var(--space-3)} +.fx-card>p{font-size:var(--text-sm)} +.fx-list{list-style:none;margin:var(--space-6) 0 0;padding:0;display:grid;gap:.55rem} +.fx-list li{position:relative;padding-left:1.7rem;font-size:var(--text-sm);color:var(--text)} +.fx-list li::before{content:"";position:absolute;left:0;top:.2em;width:.95rem;height:.95rem;border:1.5px solid var(--gold);border-radius:50%;opacity:.7;transition:opacity .4s var(--ease),background .4s var(--ease)} +.fx-list li::after{content:"";position:absolute;left:.29rem;top:.42em;width:.28rem;height:.5rem;border:solid var(--gold);border-width:0 1.5px 1.5px 0;transform:rotate(45deg);opacity:.7;transition:opacity .4s var(--ease),border-color .4s var(--ease)} +.fx-card:hover .fx-list li::before{opacity:1;background:color-mix(in srgb,var(--gold) 12%,transparent)} +.fx-card:hover .fx-list li::after{opacity:1} + +/* Book Intelligence */ +.fx-intel{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:clamp(2rem,5vw,5rem);align-items:center;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:linear-gradient(180deg,color-mix(in srgb,var(--gold) 4%,transparent),transparent)} +.fx-intel-copy p{margin:var(--space-4) 0 var(--space-6)} +.fx-inline-list{list-style:none;margin:0 0 var(--space-8);padding:0;display:flex;flex-wrap:wrap;gap:.4rem .9rem;font-size:var(--text-sm);color:var(--text)} +.fx-inline-list li::before{content:"·";color:var(--gold);margin-right:.5rem} +.fx-intel-demo{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--space-4)} +.fx-excerpt p{font-family:var(--display);font-size:1.15rem;line-height:1.5;color:var(--text);margin:0 0 .8rem} +.fx-excerpt mark{background:color-mix(in srgb,var(--gold) 22%,transparent);color:inherit;padding:0 .15em;border-bottom:1px solid var(--gold)} +.fx-insight h3{font-size:1.1rem;margin-bottom:var(--space-3)} +.fx-insight p{font-size:var(--text-sm)} +.fx-note{display:block;margin-top:var(--space-4);font-size:var(--text-xs);color:var(--muted)} + +/* Genres */ +.fx-genres{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:clamp(2rem,5vw,5rem);align-items:center} +.fx-genre-copy p{margin-top:var(--space-4)} +.fx-chips{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.6rem} +.fx-chips li{border:1px solid var(--line);border-radius:999px;padding:.5rem 1rem;font-size:var(--text-sm);color:var(--text);background:var(--surface);transition:transform .45s var(--ease),border-color .45s var(--ease),background .45s var(--ease)} +.fx-chips li.nf{border-style:dashed} +.fx-chips li:hover{transform:translateY(-3px);border-color:var(--gold);background:color-mix(in srgb,var(--gold) 10%,var(--surface))} + +/* Close */ +.fx-close{text-align:center;padding-top:clamp(3rem,7vw,6rem);padding-bottom:clamp(3rem,7vw,6rem)} +.fx-close h2{margin-bottom:var(--space-8)} +.fx-close .hero-note{margin-top:var(--space-4)} + +/* Reveal on scroll */ +.fx-reveal{opacity:0;transform:translateY(18px);transition:opacity .9s var(--ease),transform .9s var(--ease)} +.fx-reveal.is-in{opacity:1;transform:none} +.fx-card.fx-reveal.is-in{transform:none} +.fx-card.fx-reveal.is-in:hover{transform:translateY(-6px)} + +@media(max-width:960px){ + .fx-hero,.fx-intel,.fx-genres{grid-template-columns:1fr} + .fx-columns{grid-template-columns:1fr} + .fx h1{max-width:none} + .fx-window-body{grid-template-columns:1fr} + .fx-window-nav{display:none} +} +@media(max-width:600px){ + .fx-intel-demo{grid-template-columns:1fr} + .fx-finding{flex-direction:column;align-items:flex-start} + .fx-card{padding:var(--space-6)} +} +@media(prefers-reduced-motion:reduce){ + .fx-reveal{opacity:1;transform:none;transition:none} + .fx-line{stroke-dashoffset:0;animation:none!important} + .fx-dots circle{opacity:1;animation:none!important} + .fx-card,.fx-chips li,.fx-window,.fx-icon,.fx-inspect{transition:none} + .fx-card:hover,.fx-chips li:hover{transform:none} +} diff --git a/features.html b/features.html index c5c165a..66833ee 100644 --- a/features.html +++ b/features.html @@ -1,275 +1,205 @@ - - - -AuthorScrolls — Product Features - - + + + + + AuthorScrolls | Features + + + + + + + + - - - - - -
-
Product Features
-

The manuscript diagnostic
built for serious authors.

-

Not a grammar checker. Not a spell-checker. A structural engine that reads your manuscript the way an editor does — and tells you exactly what isn't working, before you pay someone else to find it.

- Diagnose Your Manuscript Free -
- -
◆
- - -
-
-
19
Genres detected automatically
-
12
Independently scored dimensions
-
2,000+
Grammar rules via LanguageTool
-
0
Inflated scores — everything starts at zero
-
-
- - -
- -
Runs locally. Results in seconds.
-
The analysis engine runs entirely in your browser. Nothing is sent to a server until you choose to use AI features. Your manuscript stays private.
- -
-
-
Free
-
🚫
-

DNF Prediction Engine

-

Scores your manuscript on six reader-dropout dimensions: hook strength, clarity, forward motion, specificity, redundancy, and payoff. Tells you which section loses readers first and exactly why.

-
-
-
Free
-
📈
-

Plot & Structure

-

Detects your story arc across four quarters of the manuscript. Rising action, climax, resolution, and cliffhanger patterns scored and mapped. Flags a sagging middle before you submit.

-
-
-
Free
-
⏬
-

Pacing Heatmap

-

Visualizes the balance of action, dialogue, description, exposition, and reflection across your manuscript. Spots where your pacing stalls or rushes.

-
-
-
Free
-
💬
-

Dialogue Analysis

-

Scores tag discipline, conciseness, naturalness, and show-not-tell within dialogue. Flags exposition dumps, repetitive speech patterns, and over-attribution.

-
-
-
Free
-
👁
-

Show vs. Tell Detection

-

Maps 32 emotions to their physical manifestations. Flags "she felt angry" and suggests "jaw tightened, hands clenched, voice sharpened." Not a generic rule — a specific replacement.

-
-
-
Free
-
✍
-

Line Editing Score

-

Six sub-scores: tone consistency, flow, word precision, sentence pacing, POV discipline, and extraneous language. Each scored independently, each improvable.

-
-
-
Free
-
🔢
-

Copy Editing Detection

-

Passive voice with active-voice rewrites. Adverbs mapped to stronger verbs. 90+ clichés flagged. Weak verbs replaced. Wordy phrases trimmed. All with specific suggestions, not just flags.

-
-
-
Free
-
✏
-

Grammar & Typos

-

2,000+ grammar rules via LanguageTool: comma splices, run-ons, subject-verb agreement, confused words, tense shifts, dialogue punctuation. Proper nouns and invented names filtered to avoid false flags.

-
-
-
Free
-
🎯
-

Opening Diagnosis

-

Your first pages scored on hook strength, tension, specificity, clarity, forward motion, and genre-matching. Tells you what a cold reader — or agent — encounters in the first 500 words.

-
-
-
- -
◆
- - -
- -
Deeper analysis when you’re close to ready.
-
AI features send your manuscript to language models for analysis that goes beyond pattern matching. Used when you’re actively revising or preparing to query.
- -
-
-
Starter & Premium
-
⚡
-

Deep Narrative Critique

-

A full developmental editorial pass: character arc, stakes, conflict engine, voice consistency, pacing at the chapter level. The kind of feedback a developmental editor gives at $2,000+. Delivered in one click.

-
-
-
Starter & Premium
-
🔎
-

Weak Passage Diagnosis

-

Identifies your three weakest passages by paragraph, explains exactly why each one loses reader momentum, and suggests a concrete rewrite. Specific text from your manuscript, not generic advice.

-
-
-
Starter & Premium
-
📖
-

Query Letter Draft

-

A query letter scaffold drawn from your manuscript’s actual story beats, genre, word count, and themes. Not a template with blanks to fill — a draft built from what’s in your book.

-
-
-
Starter & Premium
-
📚
-

Comparable Titles

-

Identifies comparable published titles based on genre, themes, voice, and structural patterns in your manuscript. Gives you the comp titles agents ask for, grounded in what you actually wrote.

-
-
-
Premium
-
👥
-

Beta Reader Simulation

-

Five distinct reader personas respond to your manuscript: the casual reader, the genre purist, the literary reader, the agent, and the skeptic. Surface problems that only real readers would catch.

-
-
-
Premium
-
📊
-

Market Readiness Score

-

An honest assessment of where your manuscript stands against current publishing standards for your genre. Not a vanity score — a gap analysis between where you are and where you need to be.

-
-
-
- -
◆
- - -
- -
19 genres. Each judged on its own terms.
-
Genre context changes what good writing looks like. A thriller opening is scored on tension and pace. A self-help introduction is scored on authority and clarity of promise. The engine knows the difference.
- -
-
-

Fiction

-

Science Fiction · Fantasy · Romance · Thriller / Suspense · Mystery / Crime · Horror / Paranormal · Historical Fiction · Dystopian · Young Adult · Literary Fiction · Romantasy · Cozy Mystery · Adventure · Western

-
-
-

Nonfiction

-

Self-Help · Biography · History · True Crime · Philosophy / Religion

-

Nonfiction scoring weights authority establishment, structural clarity, reader promise, and argument momentum differently from fiction’s tension and character arcs.

-
-
-
- -
◆
- - -
-
-

What AuthorScrolls doesn’t do — and won’t claim to.

-

This tool does not replace a copy editor, line editor, or proofreader. It does not guarantee publication. It does not rewrite your book. A developmental editor, a copy editor, and a proofreader are still part of the path to a finished, publishable manuscript.

What AuthorScrolls does: finds the structural, stylistic, and craft problems you can fix yourself — before you spend money on professional editors to find them for you. Most writers go through two or three rounds of expensive editing before the core problems are addressed. This is the step that changes that.

-
-
- - -
-

Start with your manuscript.

-

No credit card. No limit on how many times you analyze. The full diagnostic engine is free.

- Analyze Your Manuscript Free › -
- - - + + + + + +
+ + +
+
+ FEATURES +

The manuscript diagnostic built for serious authors.

+

Understand how your story is built, find the writing that gets in its way, and clean the manuscript, with an engine that reads the whole thing and shows you where it looked.

+
Try the workspace
+

No credit card. The analysis engine runs in your browser.

+
+ +
+
+
The Secret GardenPartial manuscript (chapters 1–8) · 21,138 words
+
+ +
+

Narrative Structure

described, not scored
+

8 chapters from headings. Action or dialogue ranges from 29% to 65% of a chapter, highest in Chapter VIII.

+ + 100% + 50% + 0% + + + + Chapter I: 29% · 1,904 words + Chapter II: 52% · 2,699 words + Chapter III: 34% · 1,462 words + Chapter IV: 54% · 5,307 words + Chapter V: 44% · 2,233 words + Chapter VI: 38% · 2,267 words + Chapter VII: 49% · 1,988 words + Chapter VIII: 65% · 2,722 words + + 13578CHAPTER + +

Share of each chapter that is action or dialogue, rather than summary

+
+

21 of 24 named characters appear in more than one chapter

Mary, the most-mentioned, appears in 8 of 8 chapters.

+ Inspect Chapter VIII +
+
+
+
+
Real output on a public-domain novel. Open it yourself in the workspace demo.
+
+
+ + +
+
+ CORE ANALYSIS · FREE +

A complete diagnostic, in minutes.

+

Upload a Word document, PDF or text file and get a detailed reading across structure, craft and mechanics. Every score shows the counts it was built from, and anything the engine cannot measure is described rather than scored.

+
+
+
+ + Structure +

Understand the shape of your story.

+

How much of each chapter is scene against summary, where that thins or spikes, chapter length against the book’s own norm, and whether the cast persists.

+
    +
  • Narrative structure, described with the chapters to inspect
  • +
  • Pacing heatmap by kind of prose
  • +
  • Opening diagnosis
  • +
  • Reader-momentum signals
  • +
+
+
+ + Craft +

Find the writing that’s getting in the way.

+

Judged for the passage it sits in. A long sentence in a reflective stretch is measured against your other reflective stretches, not a fixed number.

+
    +
  • Dialogue: tags, monologue density, filler
  • +
  • Show versus tell candidates
  • +
  • Line editing: tone, precision, rhythm
  • +
  • Copy editing: passive voice, adverbs, repetition, clichés
  • +
+
+
+ + Mechanics +

Clean the manuscript with confidence.

+

Grammar rules written for fiction: the subjunctive, speech cadence and dialect are not errors, and a doubled object is grammar.

+
    +
  • Grammar and typos, in context
  • +
  • Optional LanguageTool pass
  • +
  • Names and invented terms recognised
  • +
  • No inflated scores, no hidden floors
  • +
+
+
+
+ + +
+
+ BOOK INTELLIGENCE · PREMIUM +

Go deeper when you’re ready to take it further.

+

Editorial reading that explains the why behind the numbers, grounded in your chapters and always marked as advice. It never changes a score.

+
    +
  • Deep critique
  • Editing roadmap
  • Opening analysis
  • Chapter breakdown
  • Comparable titles
  • Query letter
  • Beta-reader panel
  • Market readiness
  • Ask your book
  • +
+ Explore premium +
+
+
+ Example excerpt +

The air tasted like ash, and Narubi watched the skyline burn. She had survived worse, but this felt different.

+

It wasn’t just a city falling.

+

It was a choice.

+
+
+ Book Intelligence · advisory +

The emotion is summarised where the scene could carry it

+

“This felt different” names the feeling instead of letting the reader find it. The image before it, ash and a burning skyline, is doing the work; consider what Narubi does with her hands, her breath, her attention.

+ Interpretation, not measurement. Your scores are unchanged. +
+
+
+ + +
+
+ BUILT FOR YOUR STORY +

20 genres. Analysis that understands the difference.

+

Genre changes what good writing looks like. A self-help book is not judged against fiction goals, a thriller is not asked to slow down, and the left rail tells you what a reader of your genre comes for.

+
+
    +
  • Science Fiction
  • Fantasy
  • Romance
  • Thriller
  • Mystery
  • Horror
  • Historical
  • Dystopian
  • Young Adult
  • Literary Fiction
  • Romantasy
  • Cozy Mystery
  • Adventure
  • Western
  • +
  • Memoir
  • Self-Help
  • Biography
  • History
  • True Crime
  • Philosophy
  • +
+
+ + +
+

Your manuscript already contains the evidence.
See what it says.

+ +

No credit card. Your manuscript stays private.

+
+
+ + + + + + YOUR WRITING DESK AWAITS

Welcome back.

Sign in to return to your manuscripts.

+ +
or use your email
+
+ + +
+

+ +
+ +

New to AuthorScrolls?

+ +
diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js index 590d71e..e0c6d9b 100644 --- a/scripts/browser-smoke.js +++ b/scripts/browser-smoke.js @@ -426,6 +426,26 @@ function pdf(){ await page.locator('[data-wsnav="characters"]').click(); await page.locator('#workspace-nav-content').getByText('Mary',{exact:false}).first().waitFor(); console.log('PASS workspace demo: real workspace on public-domain samples, no account, no API, nothing saved'); + // Features page: no emoji, the product frame is real output, the copy names real features, + // and the surfaces move on hover. + await page.setViewportSize({width:1440,height:1000}); + await page.goto(base+'/features.html'); + await page.getByRole('heading',{name:/manuscript diagnostic/}).waitFor(); + const fxText=await page.locator('main').innerText(); + assert.ok(!/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(fxText),'no emoji on the features page'); + assert.match(fxText,/described, not scored/); + assert.match(fxText,/Narrative structure/i); + assert.ok(!/Midpoint|Climax|Escalation/.test(fxText),'no act labels the engine does not measure'); + assert.equal(await page.locator('.fx-curve').count(),1); + assert.equal(await page.locator('.fx-chips li').count(),20,'the genre count matches the app'); + const card=page.locator('.fx-card').first(); + await card.scrollIntoViewIfNeeded(); + await page.waitForFunction(()=>document.querySelector('.fx-card').classList.contains('is-in')); + const box=await card.boundingBox();await page.mouse.move(box.x+box.width/2,box.y+box.height/2); + await page.waitForFunction(()=>{const el=document.querySelector('.fx-card');return el.matches(':hover')&&getComputedStyle(el).transform!=='none';},null,{timeout:5000}); + assert.ok(true,'a card lifts on hover'); + await page.screenshot({path:path.join(root,'test-results/features-desktop.png'),fullPage:true}); + console.log('PASS features page: no emoji, real product frame, real feature list, hover motion'); await page.setViewportSize({width:375,height:812}); for(const filename of ['features.html','pricing.html','faq.html','blog.html','legal.html','profile.html']){ await page.goto(base+'/'+filename);