diff --git a/client/src/components/pipeline/arcCanvas/DeriveFromManuscriptPreview.test.jsx b/client/src/components/pipeline/arcCanvas/DeriveFromManuscriptPreview.test.jsx
new file mode 100644
index 0000000000..1764991ebf
--- /dev/null
+++ b/client/src/components/pipeline/arcCanvas/DeriveFromManuscriptPreview.test.jsx
@@ -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();
+
+ 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: '' },
+ }));
+ });
+});
diff --git a/server/lib/README.md b/server/lib/README.md
index 63c0c0852e..945bfd0ba5 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -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`. |
diff --git a/server/lib/index.js b/server/lib/index.js
index 42ff6e4977..1e061ae3ea 100644
--- a/server/lib/index.js
+++ b/server/lib/index.js
@@ -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';
diff --git a/server/lib/storyArc.js b/server/lib/storyArc.js
index 990b7c4ed0..a801a497f6 100644
--- a/server/lib/storyArc.js
+++ b/server/lib/storyArc.js
@@ -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']);
diff --git a/server/lib/storyArcLimits.js b/server/lib/storyArcLimits.js
new file mode 100644
index 0000000000..6cb142dba9
--- /dev/null
+++ b/server/lib/storyArcLimits.js
@@ -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,
+});