Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ TBD

## Added

- **Writers Room Guide.** A new deep-linkable documentation page at `/writers-room/guide` (reachable via a "Guide" link in the Writers Room header and from `⌘K` / voice). It documents the literary length ladder — Microfiction, Flash Fiction, Standard Short Story, Novelette, plus Novella/Novel for context — with both word and character bands, a page-based book-length estimate table (200/300 pages), and craft principles grouped by structure, character, prose, and revision. The length targets and principles live in a single canonical data module (`client/src/lib/writingGuide.js`, with a `classifyByWordCount()` helper) so forthcoming editor analyses — including the planned **emotional-roadmap evaluator** that charts the reader's emotional journey beat by beat — read the same source the docs render from.

- **Use a different Chrome variant for the PortOS-managed browser.** PortOS now reads a `chromePath` (and `macAppBundle` on macOS) from `data/browser-config.json`, so you can point it at Chrome Canary, Chromium, Brave, Edge, or any Chromium-based browser — separating the automation surface from your daily-driver Chrome. Setup (`./setup.sh` / `setup.ps1`) and update (`./update.sh` / `./update.ps1`) now offer to install and configure Chrome Canary automatically: on macOS via `brew install --cask google-chrome@canary`, on Windows via `winget install Google.Chrome.Canary`. The prompt is interactive-only (CI / non-TTY runs skip silently), idempotent (won't re-prompt once configured), and supports `PORTOS_USE_CANARY=1` for headless opt-in. The Browser page's Config panel exposes both fields for after-the-fact edits.

## Changed
Expand Down
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const CharacterSheet = lazyWithReload(() => import('./pages/CharacterSheet'));
const Wiki = lazyWithReload(() => import('./pages/Wiki'));
const RapidReaderPage = lazyWithReload(() => import('./pages/RapidReader'));
const WritersRoom = lazyWithReload(() => import('./pages/WritersRoom'));
const WritersRoomGuide = lazyWithReload(() => import('./pages/WritersRoomGuide'));
const Pipeline = lazyWithReload(() => import('./pages/Pipeline'));
const Sharing = lazyWithReload(() => import('./pages/Sharing'));
const Importer = lazyWithReload(() => import('./pages/Importer'));
Expand Down Expand Up @@ -260,6 +261,7 @@ export default function App() {
<Route path="universe-builder/:universeId" element={<UniverseRouteRedirect fromPrefix={/^\/universe-builder/} />} />
<Route path="universe-builder/new" element={<RedirectWithSearch to="/universes/new" />} />
<Route path="writers-room" element={<WritersRoom />} />
<Route path="writers-room/guide" element={<WritersRoomGuide />} />
<Route path="sharing" element={<Sharing />} />
<Route path="importer" element={<Importer />} />
<Route path="pipeline" element={<Pipeline />} />
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,4 @@ grep -i "what you want to do" client/src/lib/README.md
| Module | Purpose |
|---|---|
| `universeBuilderExpand.js` | `mergeExpandIntoDraft(draft, result)` — pure merge of a Universe Builder draft with the LLM expand-API response (lock honoring, category/sheet merge with `kind` precedence, canon dedupe by name/slugline/alias). Also exports `mergeVariations`, `mergeCanonByName`, and `extractPreservedFromDraft` for callers that need the building blocks (per-category Generate, save-time refetch+merge). |
| `writingGuide.js` | Canonical Writers Room reference data + craft principles rendered by the Guide page (`/writers-room/guide`): `WRITING_LENGTH_TARGETS` (microfiction→novel word/char bands), `BOOK_LENGTH_ESTIMATES` (page-based), `WRITING_PRINCIPLES`, `PLANNED_ANALYSES` (e.g. the emotional-roadmap evaluator), and `classifyByWordCount(n)` for labelling a draft's length. Future word-count gauges / length checks read from here so targets don't drift from the docs. |
1 change: 1 addition & 0 deletions client/src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ export * from './voiceLabel.js';

// === Page-scoped pure helpers ===
export * from './universeBuilderExpand.js';
export * from './writingGuide.js';
186 changes: 186 additions & 0 deletions client/src/lib/writingGuide.js
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' },
Comment thread
atomantic marked this conversation as resolved.
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' },
Comment thread
atomantic marked this conversation as resolved.
chars: { min: 200000, max: 720000, label: '200,000–720,000 chars' },
Comment thread
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];
}
105 changes: 105 additions & 0 deletions client/src/lib/writingGuide.test.js
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');
});
Comment thread
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');
});
});
23 changes: 20 additions & 3 deletions client/src/pages/WritersRoom.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { NotebookPen, PanelLeftOpen } from 'lucide-react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { NotebookPen, PanelLeftOpen, BookOpen } from 'lucide-react';
import LibraryPane from '../components/writers-room/LibraryPane';
import WorkEditor from '../components/writers-room/WorkEditor';
import ExercisePanel from '../components/writers-room/ExercisePanel';
Expand Down Expand Up @@ -144,12 +144,29 @@ export default function WritersRoom() {
</button>
<NotebookPen className="w-4 h-4 text-port-accent" />
<span className="text-sm font-semibold text-white">Writers Room</span>
<Link
to="/writers-room/guide"
className="ml-auto flex items-center gap-1 text-xs text-gray-400 hover:text-port-accent transition-colors"
title="Writing guide: length targets & craft rules"
aria-label="Writing guide"
>
<BookOpen size={14} />
<span className="hidden sm:inline">Guide</span>
</Link>
</div>
) : (
<div className="flex items-center gap-3 px-4 py-3 border-b border-port-border bg-port-card">
<NotebookPen className="w-5 h-5 text-port-accent" />
<h1 className="text-xl font-bold text-white">Writers Room</h1>
<span className="text-xs text-gray-500 hidden md:inline ml-auto">Folders, works, drafts, storyboard, and write-for-10 sprints</span>
<span className="text-xs text-gray-500 hidden lg:inline">Folders, works, drafts, storyboard, and write-for-10 sprints</span>
<Link
to="/writers-room/guide"
className="ml-auto flex items-center gap-1 text-xs text-gray-400 hover:text-port-accent transition-colors"
title="Writing guide: length targets & craft rules"
>
<BookOpen size={15} />
<span>Guide</span>
</Link>
</div>
)}

Expand Down
Loading