/* Starting a campaign, in one screen. * * `BROWSER-UX-SPEC.md` §41-42 and §7 of the M8 brief. What it replaced was a * scenario gallery followed by a placeholder modal: to begin a story you first * picked a *world*, and to make a world you opened an editor with a JSON stat * schema, a story-card table and an art picker. That is a fine way to build a * D&D module and a poor way to start a mystery. * * Two rules shaped this form. * * **Only the name is required.** Everything else has a sensible blank. A reader * who types "Westhaven" and presses Start gets a working campaign and can write * the first line themselves; the rest of the fields are there for someone who * already knows what they want. §7 is explicit that the data model must not be * a gate on beginning. * * **No genre is privileged.** There is no fantasy vocabulary in the labels, the * placeholders or the composed instructions — the same form has to fit a * western and a horror story. Genre and tone are free text with suggestions * rather than a closed list, because a closed list is a judgement about which * stories this product is for. * * The style answers are composed into the campaign's narrator instructions * (`ai_instructions`), which is the field the prompt builder already reads. No * new backend concept was invented to hold "tone" — it is a sentence in the * instructions, which is what it would have to become anyway. */ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { api } from '../api' import { useToast } from '../components' import { classifyError } from '../errors' import { blocksPlay, useModelStatus } from '../modelStatus' import { ModelSetupNotice } from '../ModelSetupNotice' const GENRES = [ 'Fantasy', 'Science fiction', 'Mystery', 'Historical', 'Western', 'Horror', 'Thriller', 'Literary', ] const TONES = [ 'Grounded and restrained', 'Warm and hopeful', 'Bleak', 'Wry', 'Tense', 'Whimsical', 'Epic', ] const POV = [ { value: 'second-present', label: 'Second person, present tense ("You walk…")' }, { value: 'first-past', label: 'First person, past tense ("I walked…")' }, { value: 'third-past', label: 'Third person, past tense ("She walked…")' }, { value: 'third-present', label: 'Third person, present tense ("She walks…")' }, ] const LENGTHS = [ { value: 'brief', label: 'Brief — a paragraph or two' }, { value: 'medium', label: 'Medium — two to four paragraphs' }, { value: 'long', label: 'Long — four or more paragraphs' }, ] const POV_SENTENCE = { 'second-present': 'Write in second person, present tense.', 'first-past': 'Write in first person, past tense.', 'third-past': 'Write in third person, past tense.', 'third-present': 'Write in third person, present tense.', } const LENGTH_SENTENCE = { brief: 'Keep responses brief — one or two paragraphs.', medium: 'Keep responses to roughly two to four paragraphs.', long: 'Write at length — four or more paragraphs.', } /** * Turns the style answers into narrator instructions. * * Exported because the composition is the one piece of real logic on this * screen and a component test can check it without rendering anything. */ export function composeInstructions({ genre, tone, pov, length, style }) { const lines = [] if (genre.trim()) lines.push(`This is a ${genre.trim().toLowerCase()} story.`) if (tone.trim()) lines.push(`The tone is ${tone.trim().toLowerCase()}.`) if (POV_SENTENCE[pov]) lines.push(POV_SENTENCE[pov]) if (LENGTH_SENTENCE[length]) lines.push(LENGTH_SENTENCE[length]) // Always last, so a reader's own words can override anything above them. if (style.trim()) lines.push(style.trim()) return lines.join(' ') } /** A text field with a datalist of suggestions — open, not a closed set. */ function SuggestField({ id, label, value, onChange, options, placeholder, hint }) { return (
{hint}
}Only a name is needed. Everything else can be changed later, or left to the story to decide.
{blocksPlay(status) &&