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
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { Loader2, X, BookText } from 'lucide-react';
import { WORLD_PREMISE_MAX } from '../../../services/apiUniverseBuilder.js';
import { ARC_LIMITS } from '../../../../../server/lib/storyArcLimits.js';

// Field-count guard for derived synopsis textareas — mirrors the server caps so
// the user isn't surprised by a 400 on commit.
// UI ceiling for derived issue synopsis textareas; the server accepts a larger
// stage input, but this review panel keeps pasted suggestions bounded.
const DERIVE_SYNOPSIS_MAX = 8000;
const DERIVE_TITLE_MAX = 300;
const ISSUE_TITLE_MAX = 300;

// Review/edit panel for the derive-from-manuscript proposal. The arc + bible
// fields and the single-volume title/synopsis are editable; each existing issue
Expand Down Expand Up @@ -48,10 +49,10 @@ export default function DeriveFromManuscriptPreview({ preview, committing, onCan
onConfirm({
arc,
bible,
volume,
volume: { ...volume, title: volume.title.slice(0, ARC_LIMITS.SEASON_TITLE_MAX) },
issues: issues.map((it) => ({
id: it.id,
title: it.title.slice(0, DERIVE_TITLE_MAX),
title: it.title.slice(0, ISSUE_TITLE_MAX),
synopsis: it.ideaLocked ? '' : it.synopsis.slice(0, DERIVE_SYNOPSIS_MAX),
})),
});
Expand All @@ -78,7 +79,7 @@ export default function DeriveFromManuscriptPreview({ preview, committing, onCan
<div className="grid gap-3 @md:grid-cols-2">
<label className="block space-y-1">
<span className="text-[11px] uppercase tracking-wider text-gray-500">Series logline</span>
<input className={inputCls} value={bible.logline} maxLength={500}
<input className={inputCls} value={bible.logline} maxLength={ARC_LIMITS.LOGLINE_MAX}
onChange={(e) => { setBible((b) => ({ ...b, logline: e.target.value })); setArc((a) => ({ ...a, logline: e.target.value })); }} />
</label>
<label className="block space-y-1">
Expand All @@ -94,13 +95,13 @@ export default function DeriveFromManuscriptPreview({ preview, committing, onCan
</label>
<label className="block space-y-1">
<span className="text-[11px] uppercase tracking-wider text-gray-500">Protagonist arc</span>
<textarea className={`${inputCls} resize-y`} rows={2} value={arc.protagonistArc} maxLength={8000}
<textarea className={`${inputCls} resize-y`} rows={2} value={arc.protagonistArc} maxLength={ARC_LIMITS.PROTAGONIST_ARC_MAX}
onChange={(e) => setArc((a) => ({ ...a, protagonistArc: e.target.value }))} />
</label>

<label className="block border-t border-port-border pt-2 space-y-2">
<span className="text-[11px] uppercase tracking-wider text-gray-500">Volume</span>
<input className={inputCls} value={volume.title} maxLength={DERIVE_TITLE_MAX} placeholder="Volume title"
<input className={inputCls} value={volume.title} maxLength={ARC_LIMITS.SEASON_TITLE_MAX} placeholder="Volume title"
onChange={(e) => setVolume((v) => ({ ...v, title: e.target.value }))} />
</label>

Expand All @@ -110,7 +111,7 @@ export default function DeriveFromManuscriptPreview({ preview, committing, onCan
<div key={it.id} className="bg-port-card border border-port-border rounded p-2 space-y-1">
<div className="flex items-center gap-2">
<span className="text-[11px] text-gray-500 shrink-0">#{it.number}</span>
<input className={inputCls} value={it.title} maxLength={DERIVE_TITLE_MAX} placeholder="Issue title"
<input className={inputCls} value={it.title} maxLength={ISSUE_TITLE_MAX} placeholder="Issue title"
aria-label={`Title for issue ${it.number}`}
onChange={(e) => setIssueField(it.id, 'title', e.target.value)} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import DeriveFromManuscriptPreview from './DeriveFromManuscriptPreview.jsx';

const preview = {
arc: { protagonistArc: '' },
bible: {},
volume: {},
issues: [{ id: 'issue-1', number: 1, title: '', currentSynopsis: '' }],
};

describe('DeriveFromManuscriptPreview field caps', () => {
it('uses server caps and trims the volume title before confirming', () => {
const onConfirm = vi.fn();
render(<DeriveFromManuscriptPreview preview={preview} committing={false} onCancel={vi.fn()} onConfirm={onConfirm} />);

const volumeTitle = screen.getByPlaceholderText('Volume title');
const protagonistArc = screen.getByText('Protagonist arc').parentElement.querySelector('textarea');
const issueTitle = screen.getByPlaceholderText('Issue title');
expect(volumeTitle.maxLength).toBe(200);
expect(protagonistArc.maxLength).toBe(4000);
expect(issueTitle.maxLength).toBe(300);

const longTitle = 'x'.repeat(250);
fireEvent.change(volumeTitle, { target: { value: longTitle } });
fireEvent.click(screen.getByRole('button', { name: /apply/i }));

expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
volume: { title: longTitle.slice(0, 200), logline: '', synopsis: '' },
}));
});
});
1 change: 1 addition & 0 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `castIntegrityPrompt.js` | The prompt-facing render of a `characterIntegrity.js` report (#6415): `renderCastIntegrity` (gap rows first, header always kept, so a budget-truncated block can never read as a clean cast) and `INTEGRITY_DEPTH_NOTES` (the `explained` / `light` / `full` rulings in the reviewer's own words). Shared by the Series foundation judge and the FableLoom whole-series editor so the two cannot drift on what a `light` character owes. |
| `bibleLimits.js` | `BIBLE_LIMITS` — the canon field length/count caps every story-bible sanitizer, Zod schema, and catalog payload upgrade measures against. A pure leaf split out of `storyBible.js` (which pulls `crypto` + `fileUtils`) so `catalogTypes.js` and the browser bundle can read the numbers alone. |
| `storyArc.js` | Canonical Arc + Season + Reader-Map shapes for pipeline arc planning. |
| `storyArcLimits.js` | Pure canonical length and count caps shared by story arc sanitizers and browser previews. |
| `styleGuide.js` | Per-series house style (tense/POV/audience/rating/reading-level/tone/conventions): `sanitizeStyleGuide` + `renderStyleGuide` generation block + enums. |
| `storyBuilderSteps.js` | Unified Story Builder ordered step definitions + helpers (`STEPS`, `STEP_IDS`, `STEP_STATUSES`, `isValidStepId`, `stepIndex`). |
| `streamLines.js` | `createLineReader(onLine, {splitRe?, maxCarry?})` + `createOutputTail({budget?})` — buffered chunk→line splitter for child-process stdout/stderr. Carries the partial trailing line across `data` chunks and `flush()`es the final unterminated line on `close`; carry clamp guards a newline-less runaway stream. One reader per stream (a shared buffer corrupts marker lines). `splitRe: /[\r\n]+/` handles torch/tqdm bare-`\r` progress redraws. `createOutputTail` is the sibling rolling buffer of recent lines (char-budgeted, decoration-only lines skipped so an ASCII-art banner cannot push the real error out) that turns "exited with code 1" into what the tool actually printed — used by `streamingSpawn.js`. |
Expand Down
1 change: 1 addition & 0 deletions server/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export * from './seriesCharacterArc.js';
export * from './llmRoutePin.js';
export * from './seriesLlmOverride.js';
export * from './storyArc.js';
export * from './storyArcLimits.js';
export * from './styleGuide.js';
export * from './storyBuilderIntegrity.js';
export * from './storyBuilderSteps.js';
Expand Down
32 changes: 2 additions & 30 deletions server/lib/storyArc.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,36 +19,8 @@
import { randomUUID } from 'crypto';
import { isStr, trimTo, trimToClause } from './storyBible.js';
import { sanitizeCoverLike } from './renderSlot.js';

export const ARC_LIMITS = Object.freeze({
LOGLINE_MAX: 500,
SUMMARY_MAX: 8000,
PROTAGONIST_ARC_MAX: 4000,
THEME_MAX: 100,
THEMES_PER_ARC_MAX: 20,
// Season
SEASON_TITLE_MAX: 200,
SEASON_LOGLINE_MAX: 500,
// A season synopsis covers a whole season's worth of episodes (8+ issues on a
// multi-season series), so it needs the same room as the arc-level SUMMARY_MAX
// (8000). The old 4000 cap clipped a full synopsis mid-sentence — and because
// the arc-verify→resolve loop re-flags a mid-sentence truncation and the
// resolver regenerates a >4000 synopsis that gets re-clipped, the loop could
// never converge (it burned all its rounds and paused). See arc-verify
// "truncated mid-sentence" finding, 2026-06-21.
SEASON_SYNOPSIS_MAX: 8000,
SEASON_ENDING_HOOK_MAX: 1000,
SEASON_NUMBER_MAX: 99,
SEASON_EPISODE_COUNT_MAX: 999,
SEASONS_PER_SERIES_MAX: 50,
// One issue/episode planning synopsis. This is deliberately smaller than a
// whole-volume synopsis: it is a drafting seed, not a place to accumulate
// every continuity exception the verifier has ever raised. Keep the value in
// the shared arc limits so initial episode generation and later arc repairs
// cannot silently disagree about how much text one episode may own.
EPISODE_LOGLINE_MAX: 500,
EPISODE_SYNOPSIS_MAX: 4000,
});
import { ARC_LIMITS } from './storyArcLimits.js';
export { ARC_LIMITS } from './storyArcLimits.js';

export const ARC_STATUSES = Object.freeze(['draft', 'verified']);
export const SEASON_STATUSES = Object.freeze(['draft', 'verified', 'in-production', 'complete']);
Expand Down
29 changes: 29 additions & 0 deletions server/lib/storyArcLimits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/** Canonical length and count caps for story arcs, seasons, and episodes. */
export const ARC_LIMITS = Object.freeze({
LOGLINE_MAX: 500,
SUMMARY_MAX: 8000,
PROTAGONIST_ARC_MAX: 4000,
THEME_MAX: 100,
THEMES_PER_ARC_MAX: 20,
SEASON_TITLE_MAX: 200,
SEASON_LOGLINE_MAX: 500,
// A season synopsis covers a whole season's worth of episodes (8+ issues on a
// multi-season series), so it needs the same room as the arc-level SUMMARY_MAX
// (8000). The old 4000 cap clipped a full synopsis mid-sentence — and because
// the arc-verify→resolve loop re-flags a mid-sentence truncation and the
// resolver regenerates a >4000 synopsis that gets re-clipped, the loop could
// never converge (it burned all its rounds and paused). See arc-verify
// "truncated mid-sentence" finding, 2026-06-21.
SEASON_SYNOPSIS_MAX: 8000,
SEASON_ENDING_HOOK_MAX: 1000,
SEASON_NUMBER_MAX: 99,
SEASON_EPISODE_COUNT_MAX: 999,
SEASONS_PER_SERIES_MAX: 50,
// One issue/episode planning synopsis. This is deliberately smaller than a
// whole-volume synopsis: it is a drafting seed, not a place to accumulate
// every continuity exception the verifier has ever raised. Keep the value in
// the shared arc limits so initial episode generation and later arc repairs
// cannot silently disagree about how much text one episode may own.
EPISODE_LOGLINE_MAX: 500,
EPISODE_SYNOPSIS_MAX: 4000,
});