-
Notifications
You must be signed in to change notification settings - Fork 6
feat: add Writers Room Guide page (length targets + craft rules) #488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0aed80c
feat: add Writers Room Guide page (length targets + craft rules)
atomantic 0036a92
fix: add aria-label to mobile icon-only Writers Room guide link
atomantic c35e55d
address review (copilot): make length-band boundaries deterministic a…
atomantic 01bfef4
address review (copilot): inclusive microfiction labels + stricter la…
atomantic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| // Canonical Writers Room reference data: length targets, book-length | ||
| // estimates, and craft principles. The Guide page renders directly from these | ||
| // arrays, and forthcoming editor features (word-count gauges, the emotional- | ||
| // roadmap evaluator) read the same source so targets never drift between the | ||
| // docs and the tools that enforce them. | ||
| // | ||
| // Character ranges follow the conventional English estimate of ~5–6 characters | ||
| // per word (≈5 letters + 1 space). Min chars use 5×words, max chars use 6×words; | ||
| // the literary-category rows below preserve the exact figures from the editorial | ||
| // brief and the book table preserves its 5.5–6× page-based estimate. | ||
|
|
||
| // Literary length ladder — microfiction through novel. `words`/`chars` carry | ||
| // `{ min, max }` in absolute counts (null `min`/`max` = open-ended bound) plus a | ||
| // human `label` for display. `core` marks the four categories from the original | ||
| // brief; novella/novel complete the ladder for context. | ||
| export const WRITING_LENGTH_TARGETS = [ | ||
| { | ||
| id: 'microfiction', | ||
| label: 'Microfiction', | ||
| core: true, | ||
| words: { min: null, max: 500, label: '≤500 words' }, | ||
| chars: { min: null, max: 3000, label: '≤2,500–3,000 chars' }, | ||
| note: 'A single sharp image or turn. Every word load-bearing; no room for sub-plots.', | ||
| }, | ||
| { | ||
| id: 'flash-fiction', | ||
| label: 'Flash Fiction', | ||
| core: true, | ||
| words: { min: 750, max: 1000, label: '750–1,000 words' }, | ||
| chars: { min: 3750, max: 6000, label: '3,750–6,000 chars' }, | ||
| note: 'One scene, one decisive moment. Implies the world rather than building it.', | ||
| }, | ||
| { | ||
| id: 'short-story', | ||
| label: 'Standard Short Story', | ||
| core: true, | ||
| words: { min: 1500, max: 7500, label: '1,500–7,500 words' }, | ||
| chars: { min: 7500, max: 45000, label: '7,500–45,000 chars' }, | ||
| note: 'Room for a complete arc with a small cast. The default short-form target.', | ||
| }, | ||
| { | ||
| id: 'novelette', | ||
| label: 'Novelette / Long Short Story', | ||
| core: true, | ||
| words: { min: 7500, max: 17500, label: '7,500–17,500 words' }, | ||
| chars: { min: 37500, max: 105000, label: '37,500–105,000 chars' }, | ||
| note: 'Subplots and a fuller secondary cast become viable. Longer than most magazines buy.', | ||
| }, | ||
| { | ||
| id: 'novella', | ||
| label: 'Novella', | ||
| core: false, | ||
| words: { min: 17500, max: 40000, label: '17,500–40,000 words' }, | ||
| chars: { min: 87500, max: 240000, label: '87,500–240,000 chars' }, | ||
| note: 'A single dominant throughline with depth — too long for a magazine, too short for a typical print novel.', | ||
| }, | ||
| { | ||
| id: 'novel', | ||
| label: 'Novel', | ||
| core: false, | ||
| words: { min: 40000, max: 120000, label: '40,000–120,000 words' }, | ||
|
atomantic marked this conversation as resolved.
|
||
| chars: { min: 200000, max: 720000, label: '200,000–720,000 chars' }, | ||
|
atomantic marked this conversation as resolved.
|
||
| note: 'Multiple arcs and a full cast. Genre sets the sweet spot (≈70k YA, ≈100k+ epic fantasy).', | ||
| }, | ||
| ]; | ||
|
|
||
| // Page-based book-length estimate. A printed page holds ~250–300 words depending | ||
| // on trim size, font, margins, and genre — these are planning estimates, not | ||
| // guarantees. `words`/`chars` carry `{ min, max }` absolute counts plus a label. | ||
| export const BOOK_LENGTH_ESTIMATES = [ | ||
| { | ||
| id: 'book-200', | ||
| label: '200 pages', | ||
| wordsPerPage: '250–300 words/page', | ||
| words: { min: 50000, max: 60000, label: '50,000–60,000 words' }, | ||
| chars: { min: 275000, max: 360000, label: '275,000–360,000 chars' }, | ||
| }, | ||
| { | ||
| id: 'book-300', | ||
| label: '300 pages', | ||
| wordsPerPage: '250–300 words/page', | ||
| words: { min: 75000, max: 90000, label: '75,000–90,000 words' }, | ||
| chars: { min: 412500, max: 540000, label: '412,500–540,000 chars' }, | ||
| }, | ||
| ]; | ||
|
|
||
| // Craft principles the editor surfaces as advice today and will increasingly | ||
| // enforce as analysis passes ship. Each group is one card on the Guide page. | ||
| export const WRITING_PRINCIPLES = [ | ||
| { | ||
| id: 'structure', | ||
| title: 'Structure & Pacing', | ||
| summary: 'Give the reader a shape to fall into.', | ||
| rules: [ | ||
| 'Open in motion — start the scene as late as possible and end it as early as you can.', | ||
| 'Every scene should change something: a value shifts, a question is answered, a new one opens.', | ||
| 'Vary rhythm — follow a long, dense passage with a short, sharp one so tension has somewhere to land.', | ||
| 'Plant before you pay off. A reveal only lands if its setup was visible (but not obvious) earlier.', | ||
| ], | ||
| }, | ||
| { | ||
| id: 'character', | ||
| title: 'Character & Voice', | ||
| summary: 'Readers follow people, not plots.', | ||
| rules: [ | ||
| 'Give the protagonist a want (external goal) and a need (internal lack) that pull against each other.', | ||
| 'Reveal character through choice under pressure, not through narrated description.', | ||
| 'Keep each character’s voice distinct enough that dialogue is attributable without tags.', | ||
| 'Let secondary characters want things too — a cast of mirrors flattens the page.', | ||
| ], | ||
| }, | ||
| { | ||
| id: 'prose', | ||
| title: 'Prose & Clarity', | ||
| summary: 'The sentence is the unit of trust.', | ||
| rules: [ | ||
| 'Show through concrete, sensory detail; tell only to compress time or summarize the known.', | ||
| 'Prefer strong verbs and specific nouns over adverb-and-adjective stacks.', | ||
| 'Cut filter words (felt, saw, noticed, realized) that put distance between reader and experience.', | ||
| 'Stay in one point of view per scene unless a break is deliberate and signposted.', | ||
| ], | ||
| }, | ||
| { | ||
| id: 'revision', | ||
| title: 'Revision Discipline', | ||
| summary: 'Drafts are for discovery; revision is for the reader.', | ||
| rules: [ | ||
| 'First draft for yourself, second draft for the story, third draft for the reader.', | ||
| 'Read aloud — the ear catches clumsy rhythm and repetition the eye skims past.', | ||
| 'Kill your darlings: if a beautiful line doesn’t serve the scene, it serves your ego.', | ||
| 'Track length against the target band for the form; over-length usually means an unearned subplot.', | ||
| ], | ||
| }, | ||
| ]; | ||
|
|
||
| // Analysis passes the editor performs or will perform. The emotional-roadmap | ||
| // evaluator is the headline forthcoming feature called out in the brief; listing | ||
| // these here keeps the Guide honest about what is live vs. planned. | ||
| export const PLANNED_ANALYSES = [ | ||
| { | ||
| id: 'emotional-roadmap', | ||
| title: 'Emotional Roadmap', | ||
| status: 'planned', | ||
| summary: | ||
| 'Evaluate the story beat by beat to chart the emotional journey the reader will experience — where tension rises and falls, where the highs and lows land, and whether the curve delivers a satisfying arc rather than a flat line.', | ||
| }, | ||
| { | ||
| id: 'length-check', | ||
| title: 'Length & Form Fit', | ||
| status: 'planned', | ||
| summary: | ||
| 'Compare the live word/character count against the target band for the chosen form and flag a work that is drifting under- or over-length for its category.', | ||
| }, | ||
| ]; | ||
|
|
||
| // Resolve a word count to its literary-ladder category. The ladder is ordered | ||
| // ascending by upper bound, and the brief's bands have gaps (≤500 then 750–1000 | ||
| // then 1,500–7,500…), so we match on the first band whose `max` the count fits | ||
| // under rather than requiring `min ≤ count ≤ max` — a 600-word draft rounds up | ||
| // to flash instead of falling into a gap. | ||
| // | ||
| // Boundary handling: the conventional literary ladder shares boundary values | ||
| // between adjacent bands (a 7,500-word piece is the floor of "novelette" and the | ||
| // ceiling of "short story"; likewise 17,500 and 40,000). The display labels keep | ||
| // the conventional inclusive ranges, but classification must be deterministic, so | ||
| // at a shared boundary the HIGHER band wins (7,500 → novelette, 17,500 → novella, | ||
| // 40,000 → novel). We implement that by treating a band's `max` as exclusive when | ||
| // it equals the next band's `min`; otherwise the `max` is inclusive (so the gap | ||
| // rounding above still works — 600 ≤ flash.max 1000 with no overlap to skip). | ||
| // | ||
| // Returns null only for invalid input; anything above every band is a (large) | ||
| // novel. Future word-count gauges call this to label a draft. | ||
| export function classifyByWordCount(wordCount) { | ||
| if (typeof wordCount !== 'number' || !Number.isFinite(wordCount) || wordCount < 0) return null; | ||
| for (let i = 0; i < WRITING_LENGTH_TARGETS.length; i++) { | ||
| const target = WRITING_LENGTH_TARGETS[i]; | ||
| const max = target.words.max; | ||
| if (max == null) return target; // open-ended top band | ||
| const next = WRITING_LENGTH_TARGETS[i + 1]; | ||
| // At a shared boundary (this.max === next.min) the higher band owns the | ||
| // boundary value, so treat max as exclusive there; otherwise inclusive. | ||
| const boundaryBelongsToNext = next != null && next.words.min === max; | ||
| if (boundaryBelongsToNext ? wordCount < max : wordCount <= max) return target; | ||
| } | ||
| return WRITING_LENGTH_TARGETS[WRITING_LENGTH_TARGETS.length - 1]; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { | ||
| WRITING_LENGTH_TARGETS, | ||
| BOOK_LENGTH_ESTIMATES, | ||
| WRITING_PRINCIPLES, | ||
| PLANNED_ANALYSES, | ||
| classifyByWordCount, | ||
| } from './writingGuide.js'; | ||
|
|
||
| describe('writingGuide data shape', () => { | ||
| it('every length target carries id, label, and word/char bands with labels', () => { | ||
| for (const t of WRITING_LENGTH_TARGETS) { | ||
| expect(typeof t.id).toBe('string'); | ||
| expect(t.id.length).toBeGreaterThan(0); | ||
| expect(typeof t.label).toBe('string'); | ||
| expect(typeof t.words.label).toBe('string'); | ||
| expect(typeof t.chars.label).toBe('string'); | ||
| expect(typeof t.core).toBe('boolean'); | ||
| } | ||
| }); | ||
|
|
||
| it('preserves the four core categories from the brief', () => { | ||
| const core = WRITING_LENGTH_TARGETS.filter((t) => t.core).map((t) => t.id); | ||
| expect(core).toEqual(['microfiction', 'flash-fiction', 'short-story', 'novelette']); | ||
| }); | ||
|
|
||
| it('orders the ladder by ascending upper word bound (with one open-ended top band)', () => { | ||
| const maxes = WRITING_LENGTH_TARGETS.map((t) => t.words.max); | ||
| for (let i = 1; i < maxes.length; i++) { | ||
| const prev = maxes[i - 1]; | ||
| const cur = maxes[i]; | ||
| // A null max is only allowed on the final, open-ended band — and any prior | ||
| // band must therefore have a finite max (a null `prev` here means an | ||
| // open-ended band was misplaced earlier in the ladder). | ||
| expect(prev).not.toBeNull(); | ||
| if (cur == null) { | ||
| expect(i).toBe(maxes.length - 1); | ||
| } else { | ||
| expect(cur).toBeGreaterThan(prev); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| it('book estimates carry page label, words/page, and word/char bands', () => { | ||
| expect(BOOK_LENGTH_ESTIMATES.length).toBeGreaterThan(0); | ||
| for (const b of BOOK_LENGTH_ESTIMATES) { | ||
| expect(typeof b.label).toBe('string'); | ||
| expect(typeof b.wordsPerPage).toBe('string'); | ||
| expect(typeof b.words.label).toBe('string'); | ||
| expect(typeof b.chars.label).toBe('string'); | ||
| } | ||
| }); | ||
|
|
||
| it('exposes principle groups with rules and at least one planned analysis', () => { | ||
| expect(WRITING_PRINCIPLES.length).toBeGreaterThan(0); | ||
| for (const g of WRITING_PRINCIPLES) { | ||
| expect(typeof g.title).toBe('string'); | ||
| expect(Array.isArray(g.rules)).toBe(true); | ||
| expect(g.rules.length).toBeGreaterThan(0); | ||
| } | ||
| expect(PLANNED_ANALYSES.some((a) => a.id === 'emotional-roadmap')).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('classifyByWordCount', () => { | ||
| it('rejects invalid input with null', () => { | ||
| expect(classifyByWordCount(undefined)).toBeNull(); | ||
| expect(classifyByWordCount(NaN)).toBeNull(); | ||
| expect(classifyByWordCount(-10)).toBeNull(); | ||
| expect(classifyByWordCount('1000')).toBeNull(); | ||
| }); | ||
|
|
||
| it('labels counts within a band', () => { | ||
| expect(classifyByWordCount(0).id).toBe('microfiction'); | ||
| expect(classifyByWordCount(500).id).toBe('microfiction'); | ||
| expect(classifyByWordCount(900).id).toBe('flash-fiction'); | ||
| expect(classifyByWordCount(5000).id).toBe('short-story'); | ||
| expect(classifyByWordCount(12000).id).toBe('novelette'); | ||
| expect(classifyByWordCount(25000).id).toBe('novella'); | ||
| }); | ||
|
|
||
| it('rounds a gap count up to the next band', () => { | ||
| // 600 sits between microfiction (≤500) and flash (750–1000) → rounds to flash. | ||
| expect(classifyByWordCount(600).id).toBe('flash-fiction'); | ||
| // 1200 sits between flash (≤1000) and short story (1500–7500) → short story. | ||
| expect(classifyByWordCount(1200).id).toBe('short-story'); | ||
| }); | ||
|
|
||
| it('treats anything above every band as a novel', () => { | ||
| expect(classifyByWordCount(500000).id).toBe('novel'); | ||
| }); | ||
|
atomantic marked this conversation as resolved.
|
||
|
|
||
| it('assigns shared boundary values to the higher band', () => { | ||
| // Adjacent bands share boundary values by literary convention (the lower | ||
| // band's max equals the higher band's min). Classification must be | ||
| // deterministic, with the higher band owning the boundary. | ||
| expect(classifyByWordCount(7500).id).toBe('novelette'); // short-story.max === novelette.min | ||
| expect(classifyByWordCount(17500).id).toBe('novella'); // novelette.max === novella.min | ||
| expect(classifyByWordCount(40000).id).toBe('novel'); // novella.max === novel.min | ||
| // One word below each boundary still lands in the lower band. | ||
| expect(classifyByWordCount(7499).id).toBe('short-story'); | ||
| expect(classifyByWordCount(17499).id).toBe('novelette'); | ||
| expect(classifyByWordCount(39999).id).toBe('novella'); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.