profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

T13: non-technical onboarding pass

client/src/labels.ts gives every coverage category a plain-language
label and description (CoveragePanel now also shows an "N of 9 done"
progress line). Reworded session-list and interview-screen copy to not
assume a developer audience ("Tell us about your idea", "Get your
spec", "Answer developer questions", status "In progress"/"Ready"
instead of "interviewing"/"done"). buildInterviewSystemPrompt now
explicitly instructs jargon-free, plain-language questions.

Manually verified in a headless browser: coverage labels, progress
line, and screen copy all render as expected end to end.
commit 188b833

10 changed files with +96 and −66

Jump to a changed file
  1. client/src/App.css +7 −0
  2. client/src/App.tsx +20 −13
  3. client/src/components/BlockerImport.tsx +6 −5
  4. client/src/components/CoveragePanel.tsx +21 −21
  5. client/src/components/GeneratePanel.tsx +12 −22
  6. client/src/components/QuestionCard.tsx +1 −1
  7. client/src/labels.ts +18 −0
  8. server/providers/llmAnthropic.test.ts +7 −0
  9. server/providers/llmAnthropic.ts +3 −3
  10. 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`.