Commit
T13: non-technical onboarding pass
commit
188b833
10 changed files with +96 and −66
Jump to a changed file
- client/src/App.css +7 −0
- client/src/App.tsx +20 −13
- client/src/components/BlockerImport.tsx +6 −5
- client/src/components/CoveragePanel.tsx +21 −21
- client/src/components/GeneratePanel.tsx +12 −22
- client/src/components/QuestionCard.tsx +1 −1
- client/src/labels.ts +18 −0
- server/providers/llmAnthropic.test.ts +7 −0
- server/providers/llmAnthropic.ts +3 −3
- spec/TASKS.md +1 −1
modified client/src/App.css +7 −0
| @@ -355,6 +355,13 @@ | ||
| 355 | 355 | opacity: 0.8; |
| 356 | 356 | } |
| 357 | 357 | |
| 358 | +.coverage-progress { | |
| 359 | + font-size: 13px; | |
| 360 | + color: var(--text); | |
| 361 | + opacity: 0.8; | |
| 362 | + margin: 0 0 10px; | |
| 363 | +} | |
| 364 | + | |
| 358 | 365 | .coverage-panel { |
| 359 | 366 | list-style: none; |
| 360 | 367 | padding: 0; |
modified client/src/App.tsx +20 −13
| @@ -18,6 +18,10 @@function latestQuestion(session: Session): string | null { | ||
| 18 | 18 | return null |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | +function statusLabel(status: Session['status']): string { | |
| 22 | + return status === 'done' ? 'Ready' : 'In progress' | |
| 23 | +} | |
| 24 | + | |
| 21 | 25 | function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { |
| 22 | 26 | const [sessions, setSessions] = useState<SessionSummary[]>([]) |
| 23 | 27 | const [name, setName] = useState('') |
| @@ -46,39 +50,42 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { | ||
| 46 | 50 | return ( |
| 47 | 51 | <div className="screen session-list-screen"> |
| 48 | 52 | <h1>VoiceTask</h1> |
| 49 | - <p className="subtitle">Talk through what you want to build. VoiceTask interviews you and writes the spec.</p> | |
| 53 | + <p className="subtitle"> | |
| 54 | + Talk through what you want built. We'll ask questions one at a time, then turn it into a | |
| 55 | + clear spec you can hand to whoever builds it — no technical experience needed. | |
| 56 | + </p> | |
| 50 | 57 | |
| 51 | 58 | <form className="create-session-form" onSubmit={handleCreate}> |
| 52 | - <h2>Start a new interview</h2> | |
| 59 | + <h2>Tell us about your idea</h2> | |
| 53 | 60 | <label> |
| 54 | - Project name | |
| 61 | + What should we call this project? | |
| 55 | 62 | <input value={name} onChange={(e) => setName(e.target.value)} required /> |
| 56 | 63 | </label> |
| 57 | 64 | <label> |
| 58 | - Target directory | |
| 65 | + Project folder | |
| 59 | 66 | <input |
| 60 | 67 | value={targetDir} |
| 61 | 68 | onChange={(e) => setTargetDir(e.target.value)} |
| 62 | - placeholder="/path/to/project" | |
| 69 | + placeholder="Where should we save this?" | |
| 63 | 70 | required |
| 64 | 71 | /> |
| 65 | 72 | </label> |
| 66 | 73 | <button type="submit" disabled={creating}> |
| 67 | - {creating ? 'Starting…' : 'Start interview'} | |
| 74 | + {creating ? 'Getting started…' : 'Get started'} | |
| 68 | 75 | </button> |
| 69 | 76 | </form> |
| 70 | 77 | |
| 71 | 78 | {error && <p className="error">{error}</p>} |
| 72 | 79 | |
| 73 | 80 | <div className="session-list"> |
| 74 | - <h2>Existing sessions</h2> | |
| 75 | - {sessions.length === 0 && <p className="muted">No sessions yet.</p>} | |
| 81 | + <h2>Your projects</h2> | |
| 82 | + {sessions.length === 0 && <p className="muted">Nothing here yet — start your first project above.</p>} | |
| 76 | 83 | <ul> |
| 77 | 84 | {sessions.map((session) => ( |
| 78 | 85 | <li key={session.id}> |
| 79 | 86 | <button type="button" className="session-item" onClick={() => onOpen(session.id)}> |
| 80 | 87 | <span className="session-name">{session.name}</span> |
| 81 | - <span className={`session-status status-${session.status}`}>{session.status}</span> | |
| 88 | + <span className={`session-status status-${session.status}`}>{statusLabel(session.status)}</span> | |
| 82 | 89 | </button> |
| 83 | 90 | </li> |
| 84 | 91 | ))} |
| @@ -150,7 +157,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 150 | 157 | return ( |
| 151 | 158 | <div className="screen interview-screen"> |
| 152 | 159 | <button type="button" className="back-link" onClick={onBack}> |
| 153 | - ← Back to sessions | |
| 160 | + ← Back to your projects | |
| 154 | 161 | </button> |
| 155 | 162 | {error ? <p className="error">{error}</p> : <p>Loading…</p>} |
| 156 | 163 | </div> |
| @@ -160,7 +167,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 160 | 167 | return ( |
| 161 | 168 | <div className="screen interview-screen"> |
| 162 | 169 | <button type="button" className="back-link" onClick={onBack}> |
| 163 | - ← Back to sessions | |
| 170 | + ← Back to your projects | |
| 164 | 171 | </button> |
| 165 | 172 | <div className="interview-header"> |
| 166 | 173 | <h1>{session.name}</h1> |
| @@ -205,7 +212,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 205 | 212 | </> |
| 206 | 213 | ) : ( |
| 207 | 214 | <> |
| 208 | - <p className="interview-done">Interview complete.</p> | |
| 215 | + <p className="interview-done">You're all set! Here's what's next.</p> | |
| 209 | 216 | <GeneratePanel sessionId={session.id} coverage={session.coverage} /> |
| 210 | 217 | <BlockerImport sessionId={session.id} onImported={refresh} /> |
| 211 | 218 | </> |
| @@ -215,7 +222,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 215 | 222 | </div> |
| 216 | 223 | |
| 217 | 224 | <aside className="interview-sidebar"> |
| 218 | - <h2>Coverage</h2> | |
| 225 | + <h2>Progress</h2> | |
| 219 | 226 | <CoveragePanel coverage={session.coverage} /> |
| 220 | 227 | </aside> |
| 221 | 228 | </div> |
modified client/src/components/BlockerImport.tsx +6 −5
| @@ -18,9 +18,9 @@export function BlockerImport({ sessionId, onImported }: BlockerImportProps) { | ||
| 18 | 18 | try { |
| 19 | 19 | const { questions } = await api.importBlockers(sessionId) |
| 20 | 20 | if (questions.length === 0) { |
| 21 | - setMessage('No blockers found in BLOCKED.md.') | |
| 21 | + setMessage("Didn't find any open questions in BLOCKED.md.") | |
| 22 | 22 | } else { |
| 23 | - setMessage(`Imported ${questions.length} blocker question(s). Resuming the interview.`) | |
| 23 | + setMessage(`Found ${questions.length} open question(s). Answer them below.`) | |
| 24 | 24 | onImported() |
| 25 | 25 | } |
| 26 | 26 | } catch (err) { |
| @@ -32,12 +32,13 @@export function BlockerImport({ sessionId, onImported }: BlockerImportProps) { | ||
| 32 | 32 | |
| 33 | 33 | return ( |
| 34 | 34 | <div className="blocker-import"> |
| 35 | - <h2>Import blockers</h2> | |
| 35 | + <h2>Answer developer questions</h2> | |
| 36 | 36 | <p className="muted"> |
| 37 | - Reads BLOCKED.md from the target directory and resumes the interview with its open questions. | |
| 37 | + If whoever is building this left open questions in a file called BLOCKED.md in the project | |
| 38 | + folder, check here to answer them. | |
| 38 | 39 | </p> |
| 39 | 40 | <button type="button" onClick={() => void handleImport()} disabled={importing}> |
| 40 | - {importing ? 'Importing…' : 'Import BLOCKED.md'} | |
| 41 | + {importing ? 'Checking…' : 'Check for questions'} | |
| 41 | 42 | </button> |
| 42 | 43 | {message && <p>{message}</p>} |
| 43 | 44 | {error && <p className="error">{error}</p>} |
modified client/src/components/CoveragePanel.tsx +21 −21
| @@ -1,30 +1,30 @@ | ||
| 1 | -import { CATEGORY_IDS, type CategoryId, type Coverage } from 'shared/types' | |
| 1 | +import { CATEGORY_IDS, type Coverage } from 'shared/types' | |
| 2 | +import { CATEGORY_LABELS } from '../labels' | |
| 2 | 3 | |
| 3 | 4 | interface CoveragePanelProps { |
| 4 | 5 | coverage: Coverage |
| 5 | 6 | } |
| 6 | 7 | |
| 7 | -const LABELS: Record<CategoryId, string> = { | |
| 8 | - goal: 'Goal', | |
| 9 | - users: 'Users', | |
| 10 | - 'core-flow': 'Core flow', | |
| 11 | - data: 'Data', | |
| 12 | - integrations: 'Integrations', | |
| 13 | - 'edge-cases': 'Edge cases', | |
| 14 | - constraints: 'Constraints', | |
| 15 | - 'non-goals': 'Non-goals', | |
| 16 | - verification: 'Verification', | |
| 17 | -} | |
| 18 | - | |
| 19 | 8 | export function CoveragePanel({ coverage }: CoveragePanelProps) { |
| 9 | + const clearedCount = CATEGORY_IDS.filter((category) => coverage[category] === 'clear').length | |
| 10 | + | |
| 20 | 11 | return ( |
| 21 | - <ul className="coverage-panel"> | |
| 22 | - {CATEGORY_IDS.map((category) => ( | |
| 23 | - <li key={category} className={`coverage-item coverage-${coverage[category]}`}> | |
| 24 | - <span className="coverage-dot" aria-hidden="true" /> | |
| 25 | - {LABELS[category]} | |
| 26 | - </li> | |
| 27 | - ))} | |
| 28 | - </ul> | |
| 12 | + <div> | |
| 13 | + <p className="coverage-progress"> | |
| 14 | + {clearedCount} of {CATEGORY_IDS.length} done | |
| 15 | + </p> | |
| 16 | + <ul className="coverage-panel"> | |
| 17 | + {CATEGORY_IDS.map((category) => ( | |
| 18 | + <li | |
| 19 | + key={category} | |
| 20 | + className={`coverage-item coverage-${coverage[category]}`} | |
| 21 | + title={CATEGORY_LABELS[category].description} | |
| 22 | + > | |
| 23 | + <span className="coverage-dot" aria-hidden="true" /> | |
| 24 | + {CATEGORY_LABELS[category].label} | |
| 25 | + </li> | |
| 26 | + ))} | |
| 27 | + </ul> | |
| 28 | + </div> | |
| 29 | 29 | ) |
| 30 | 30 | } |
modified client/src/components/GeneratePanel.tsx +12 −22
| @@ -1,24 +1,13 @@ | ||
| 1 | 1 | import { useState } from 'react' |
| 2 | -import { CATEGORY_IDS, type CategoryId, type Coverage, type GenerateResponse } from 'shared/types' | |
| 2 | +import { CATEGORY_IDS, type Coverage, type GenerateResponse } from 'shared/types' | |
| 3 | 3 | import * as api from '../api' |
| 4 | +import { CATEGORY_LABELS } from '../labels' | |
| 4 | 5 | |
| 5 | 6 | interface GeneratePanelProps { |
| 6 | 7 | sessionId: string |
| 7 | 8 | coverage: Coverage |
| 8 | 9 | } |
| 9 | 10 | |
| 10 | -const LABELS: Record<CategoryId, string> = { | |
| 11 | - goal: 'Goal', | |
| 12 | - users: 'Users', | |
| 13 | - 'core-flow': 'Core flow', | |
| 14 | - data: 'Data', | |
| 15 | - integrations: 'Integrations', | |
| 16 | - 'edge-cases': 'Edge cases', | |
| 17 | - constraints: 'Constraints', | |
| 18 | - 'non-goals': 'Non-goals', | |
| 19 | - verification: 'Verification', | |
| 20 | -} | |
| 21 | - | |
| 22 | 11 | export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) { |
| 23 | 12 | const [confirming, setConfirming] = useState(false) |
| 24 | 13 | const [generating, setGenerating] = useState(false) |
| @@ -55,32 +44,33 @@export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) { | ||
| 55 | 44 | |
| 56 | 45 | return ( |
| 57 | 46 | <div className="generate-panel"> |
| 58 | - <h2>Generate spec pack</h2> | |
| 47 | + <h2>Get your spec</h2> | |
| 59 | 48 | |
| 60 | 49 | {confirming ? ( |
| 61 | 50 | <div className="generate-confirm"> |
| 62 | 51 | <p> |
| 63 | - These categories are still missing: {missingCategories.map((c) => LABELS[c]).join(', ')}. Generate | |
| 64 | - anyway? | |
| 52 | + A few things are still empty:{' '} | |
| 53 | + {missingCategories.map((c) => CATEGORY_LABELS[c].label).join(', ')}. You can fill those in | |
| 54 | + later — continue anyway? | |
| 65 | 55 | </p> |
| 66 | 56 | <button type="button" onClick={() => void runGenerate(false)} disabled={generating}> |
| 67 | - Generate anyway | |
| 57 | + Continue anyway | |
| 68 | 58 | </button> |
| 69 | 59 | <button type="button" onClick={() => setConfirming(false)} disabled={generating}> |
| 70 | 60 | Cancel |
| 71 | 61 | </button> |
| 72 | 62 | </div> |
| 73 | 63 | ) : ( |
| 74 | 64 | <button type="button" onClick={handleGenerateClick} disabled={generating}> |
| 75 | - {generating ? 'Generating…' : 'Generate spec pack'} | |
| 65 | + {generating ? 'Creating your spec…' : 'Create my spec'} | |
| 76 | 66 | </button> |
| 77 | 67 | )} |
| 78 | 68 | |
| 79 | 69 | {needsOverwrite && ( |
| 80 | 70 | <div className="generate-overwrite"> |
| 81 | - <p>A spec pack already exists in the target directory.</p> | |
| 71 | + <p>There's already a spec in that folder.</p> | |
| 82 | 72 | <button type="button" onClick={() => void runGenerate(true)} disabled={generating}> |
| 83 | - Overwrite and regenerate | |
| 73 | + Replace it | |
| 84 | 74 | </button> |
| 85 | 75 | </div> |
| 86 | 76 | )} |
| @@ -89,15 +79,15 @@export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) { | ||
| 89 | 79 | |
| 90 | 80 | {result && ( |
| 91 | 81 | <div className="generate-result"> |
| 92 | - <p>Wrote {result.files.length} files:</p> | |
| 82 | + <p>Done! Here's what was created:</p> | |
| 93 | 83 | <ul> |
| 94 | 84 | {result.files.map((file) => ( |
| 95 | 85 | <li key={file}>{file}</li> |
| 96 | 86 | ))} |
| 97 | 87 | </ul> |
| 98 | 88 | {result.warnings.length > 0 && ( |
| 99 | 89 | <div className="generate-warnings"> |
| 100 | - <p>Warnings:</p> | |
| 90 | + <p>A couple of notes:</p> | |
| 101 | 91 | <ul> |
| 102 | 92 | {result.warnings.map((warning) => ( |
| 103 | 93 | <li key={warning}>{warning}</li> |
modified client/src/components/QuestionCard.tsx +1 −1
| @@ -7,7 +7,7 @@export function QuestionCard({ question }: QuestionCardProps) { | ||
| 7 | 7 | |
| 8 | 8 | return ( |
| 9 | 9 | <div className="question-card"> |
| 10 | - <span className="question-label">Interviewer asks</span> | |
| 10 | + <span className="question-label">Question</span> | |
| 11 | 11 | <p className="question-text">{question}</p> |
| 12 | 12 | </div> |
| 13 | 13 | ) |
added client/src/labels.ts +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +import type { CategoryId } from 'shared/types' | |
| 2 | + | |
| 3 | +export interface CategoryLabel { | |
| 4 | + label: string | |
| 5 | + description: string | |
| 6 | +} | |
| 7 | + | |
| 8 | +export const CATEGORY_LABELS: Record<CategoryId, CategoryLabel> = { | |
| 9 | + goal: { label: 'The big idea', description: 'What you want to build, and why' }, | |
| 10 | + users: { label: "Who it's for", description: 'The people who will use it' }, | |
| 11 | + 'core-flow': { label: 'How it works', description: 'The main steps someone takes to use it' }, | |
| 12 | + data: { label: 'What it remembers', description: 'The information it stores or keeps track of' }, | |
| 13 | + integrations: { label: 'What it connects to', description: 'Any other apps or services it needs to talk to' }, | |
| 14 | + 'edge-cases': { label: 'Tricky situations', description: 'What happens when something goes wrong or unusual' }, | |
| 15 | + constraints: { label: 'Rules & limits', description: 'Any must-haves, like platform, budget, or speed' }, | |
| 16 | + 'non-goals': { label: "What's NOT included", description: "Things you're deliberately leaving out for now" }, | |
| 17 | + verification: { label: "How you'll know it's done", description: 'What "finished" looks like' }, | |
| 18 | +} |
modified server/providers/llmAnthropic.test.ts +7 −0
| @@ -32,6 +32,13 @@describe('buildInterviewSystemPrompt', () => { | ||
| 32 | 32 | expect(prompt).toContain('exactly "done"') |
| 33 | 33 | expect(prompt).toContain('contradiction') |
| 34 | 34 | }) |
| 35 | + | |
| 36 | + it('instructs plain, jargon-free language for a non-technical user', () => { | |
| 37 | + const context: InterviewContext = { summary: '', segments: [], coverage: initialCoverage() } | |
| 38 | + const prompt = buildInterviewSystemPrompt(context) | |
| 39 | + expect(prompt).toContain('no jargon') | |
| 40 | + expect(prompt).toContain('may not be a developer') | |
| 41 | + }) | |
| 35 | 42 | }) |
| 36 | 43 | |
| 37 | 44 | describe('buildInterviewUserMessage', () => { |
modified server/providers/llmAnthropic.ts +3 −3
| @@ -12,13 +12,13 @@const GENERATE_MAX_TOKENS = 32000 | ||
| 12 | 12 | export function buildInterviewSystemPrompt(context: InterviewContext): string { |
| 13 | 13 | const weakest = weakestCategory(context.coverage) |
| 14 | 14 | return [ |
| 15 | - 'You are a Socratic spec interviewer for a solo developer talking through a project idea.', | |
| 16 | - 'Ask exactly one targeted question per turn, chosen to fill the biggest gap in the spec.', | |
| 15 | + 'You are a Socratic spec interviewer talking through a project idea with someone who may not be a developer or ever have written a spec before. Assume no technical background unless they use technical terms themselves.', | |
| 16 | + 'Ask exactly one targeted question per turn, chosen to fill the biggest gap in the spec. Use plain, everyday language — short sentences, no jargon, no acronyms, no words like "requirements", "schema", "edge case", or "non-goal" in the question itself. Ask about the underlying idea instead (e.g. ask what should happen when something goes wrong, not "what are the edge cases").', | |
| 17 | 17 | `Coverage categories, in tie-break priority order: ${CATEGORY_IDS.join(', ')}.`, |
| 18 | 18 | weakest |
| 19 | 19 | ? `The weakest category right now is "${weakest}". Your next question must target it, unless a contradiction takes priority.` |
| 20 | 20 | : 'Every category is already "clear".', |
| 21 | - 'If the latest user answer conflicts with an earlier statement, do not ask a coverage question: set "contradiction" to describe both statements, referencing their segment ids, instead.', | |
| 21 | + 'If the latest user answer conflicts with an earlier statement, do not ask a coverage question: set "contradiction" to describe both statements in plain language, referencing their segment ids, instead.', | |
| 22 | 22 | 'The interview ends only when the user answer is exactly "done" (case-insensitive, trimmed) — never for any other reason, even if "done" appears inside a longer answer.', |
| 23 | 23 | 'Reply with only the InterviewTurn structure described by the output schema.', |
| 24 | 24 | ].join('\n') |
modified spec/TASKS.md +1 −1
| @@ -62,7 +62,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 62 | 62 | - Depends: T9, T10 |
| 63 | 63 | - Verify: `npm test` runs it green; then `npm run typecheck`, `npm test`, `npm run build` all exit 0 as the final full check. |
| 64 | 64 | |
| 65 | -- [ ] T13 Non-technical onboarding pass | |
| 65 | +- [x] T13 Non-technical onboarding pass | |
| 66 | 66 | - `client/src/labels.ts` maps each `CategoryId` to a plain-language label and one-line explanation used by `CoveragePanel`. Rewrite session-list and interview-screen copy (headings, empty states, button text) to not assume the reader is a developer. Update `buildInterviewSystemPrompt` in `llmAnthropic.ts` to instruct plain-language, jargon-free questions per updated SPEC FR-006. |
| 67 | 67 | - Depends: T7 |
| 68 | 68 | - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: every category in CoveragePanel shows a plain-language label, not a `CategoryId`. |