profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

Merge origin/main: reconcile two independently-built implementations

main and spec-pack-implementation diverged from the same base
and each built out a full app independently. This merge keeps both
feature sets rather than picking one:

From main: CI workflow, README, .env.example, session delete (with
per-row confirm) and creation-date display, a 120s recording cap,
audio-level metering feeding a live waveform, the VoiceOrb/Waveform
voice-state visualization, Escape-to-stop-speech, @anthropic-ai/sdk
bumped to 0.113.0 (which promoted messages.parse/zodOutputFormat/
adaptive-thinking out of beta - PLAN.md's original API shape now
actually exists), a retry-once-on-parse-failure wrapper for interview
turns, and zod bumped to v4.

From this branch: the folder browser (browse/create directories
instead of typing a path), spec-pack zip + transcript export/copy,
and the plain-language coverage labels/onboarding copy/interviewer
prompt from the non-technical-user pass.

Five files had real textual conflicts (App.tsx, App.css,
CoveragePanel.tsx, QuestionCard.tsx, llmAnthropic.ts); all five are
resolved by combining both sides rather than picking one. App.css
required rewriting this branch's newly-added selectors (folder
browser, transcript export, download links) onto main's new
paper/clay/ink design-system variables, since main replaced the old
palette entirely. Roughly a dozen other files modified on both sides
merged cleanly with no textual overlap.

Verified: npm run typecheck, npm test (78 tests), npm run build all
exit 0. Manually drove the fully merged app in a headless browser:
folder browser create/navigate/select, plain-language coverage labels,
a typed answer and a recorded (fake-device) voice answer both advance
coverage, transcript copy-to-clipboard, TTS default-on, generate with
the missing-category confirmation, the spec-pack zip download, and the
blocker-import panel all work together in one session.
commit 22a1c3b

26 changed files with +1541 and −460

Jump to a changed file
  1. .env.example +18 −0
  2. .github/workflows/ci.yml +22 −0
  3. .gitignore +1 −0
  4. README.md +50 −0
  5. client/index.html +10 −0
  6. client/src/App.css +820 −279
  7. client/src/App.tsx +180 −90
  8. client/src/api.ts +8 −0
  9. client/src/audio.ts +29 −2
  10. client/src/components/CoveragePanel.tsx +1 −1
  11. client/src/components/PushToTalkButton.tsx +49 −6
  12. client/src/components/QuestionCard.tsx +2 −5
  13. client/src/components/Transcript.tsx +20 −11
  14. client/src/components/VoiceOrb.tsx +57 −0
  15. client/src/components/Waveform.tsx +64 −0
  16. client/src/index.css +48 −30
  17. client/src/tts.ts +33 −2
  18. package-lock.json +35 −9
  19. package.json +7 −3
  20. server/index.ts +3 −0
  21. server/providers/llmAnthropic.ts +24 −15
  22. server/routes/sessions.test.ts +18 −1
  23. server/routes/sessions.ts +6 −0
  24. server/store/sessionStore.test.ts +13 −3
  25. server/store/sessionStore.ts +17 −2
  26. shared/types.ts +6 −1
added .env.example +18 −0
@@ -0,0 +1,18 @@
1 +# Copy to .env (or set in your shell) and fill in the keys you need.
2 +# With MOCK_PROVIDERS=1 no keys are required at all.
3 +
4 +# 1 = deterministic offline mocks for STT and LLM (used by tests and CI)
5 +MOCK_PROVIDERS=
6 +
7 +# Interview + spec generation (Anthropic)
8 +ANTHROPIC_API_KEY=
9 +# Optional. Default: claude-opus-4-8. Cheaper alternative: claude-sonnet-4-6
10 +ANTHROPIC_MODEL=
11 +
12 +# Speech-to-text (OpenAI)
13 +OPENAI_API_KEY=
14 +# Optional. Default: gpt-4o-mini-transcribe. Alternatives: gpt-4o-transcribe, whisper-1
15 +STT_MODEL=
16 +
17 +# Optional. Server port, default 3001 (Vite dev proxy expects 3001)
18 +PORT=
added .github/workflows/ci.yml +22 −0
@@ -0,0 +1,22 @@
1 +name: CI
2 +
3 +on:
4 + push:
5 + branches: [main]
6 + pull_request:
7 +
8 +jobs:
9 + check:
10 + runs-on: ubuntu-latest
11 + env:
12 + MOCK_PROVIDERS: '1'
13 + steps:
14 + - uses: actions/checkout@v4
15 + - uses: actions/setup-node@v4
16 + with:
17 + node-version: 22
18 + cache: npm
19 + - run: npm ci
20 + - run: npm run typecheck
21 + - run: npm test
22 + - run: npm run build
modified .gitignore +1 −0
@@ -1,5 +1,6 @@
1 1 node_modules
2 2 dist
3 +.env
3 4 dist-ssr
4 5 *.local
5 6 data/sessions
added README.md +50 −0
@@ -0,0 +1,50 @@
1 +# VoiceTask
2 +
3 +Talk through what you want to build. An AI interviewer asks one targeted question at a time (voice in, voice out), tracks coverage across nine spec categories, and writes a Claude-Code-ready spec pack (SPEC, PLAN, TASKS, VERIFICATION, HANDOFF) into your target project. Every requirement carries `[S<n>]` provenance markers pointing to the exact transcript segment you said it in.
4 +
5 +## Quick start
6 +
7 +```
8 +npm install
9 +
10 +# Offline demo, no API keys needed (deterministic mock providers):
11 +MOCK_PROVIDERS=1 npm run dev
12 +
13 +# Real mode:
14 +cp .env.example .env # fill in the keys
15 +npm run dev
16 +```
17 +
18 +Open the printed Vite URL in Chrome or Edge. Hold the mic button (or space) to talk, or type. Say "done" or click the done link to finish, then generate the spec pack.
19 +
20 +## APIs and models
21 +
22 +| Purpose | Provider | Env vars | Default model | Alternatives |
23 +|---|---|---|---|---|
24 +| Interview + spec generation | Anthropic Messages API | `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` | `claude-opus-4-8` | `claude-sonnet-4-6` (cheaper, faster) |
25 +| Speech-to-text | OpenAI audio transcriptions | `OPENAI_API_KEY`, `STT_MODEL` | `gpt-4o-mini-transcribe` | `gpt-4o-transcribe` (higher accuracy), `whisper-1` |
26 +| Text-to-speech | Browser `speechSynthesis` | none | best installed voice | none (free, local) |
27 +
28 +Notes:
29 +
30 +- The interviewer uses structured outputs (`output_config.format` with a zod schema) with adaptive thinking, so every turn comes back as a validated `InterviewTurn`. A schema-mismatch is retried once, then surfaced as an error.
31 +- Spec pack files are generated one Anthropic call per file, streamed, with adaptive thinking.
32 +- `MOCK_PROVIDERS=1` swaps both providers for deterministic mocks; all tests run this way and need no network.
33 +- Keys are read from the environment (a local `.env` is loaded if present) and never logged or written to disk.
34 +
35 +## Environment variables
36 +
37 +See `.env.example`. Summary: `MOCK_PROVIDERS`, `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `OPENAI_API_KEY`, `STT_MODEL`, `PORT` (default 3001).
38 +
39 +## Commands
40 +
41 +```
42 +npm run dev # server (3001) + client (Vite) with proxy
43 +npm run typecheck # tsc --noEmit over shared, server, client
44 +npm test # vitest, mock providers enforced
45 +npm run build # production build to dist/
46 +```
47 +
48 +## Project docs
49 +
50 +Product spec and implementation plan live in `spec/`. Guardrails for coding-agent sessions are in `CLAUDE.md`, blocker protocol in `BLOCKED.md`.
modified client/index.html +10 −0
@@ -4,6 +4,16 @@
4 4 <meta charset="UTF-8" />
5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 6 <title>VoiceTask</title>
7 + <link
8 + rel="icon"
9 + href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='13' fill='%23c46a4a'/%3E%3Crect x='11' y='12' width='2.6' height='7' rx='1.3' fill='%23f6f1e7'/%3E%3Crect x='18.4' y='12' width='2.6' height='7' rx='1.3' fill='%23f6f1e7'/%3E%3C/svg%3E"
10 + />
11 + <link rel="preconnect" href="https://fonts.googleapis.com" />
12 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
13 + <link
14 + href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..700&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap"
15 + rel="stylesheet"
16 + />
7 17 </head>
8 18 <body>
9 19 <div id="root"></div>
modified client/src/App.css +820 −279
@@ -1,65 +1,123 @@
1 1 .screen {
2 - max-width: 960px;
2 + position: relative;
3 + max-width: 680px;
3 4 margin: 0 auto;
4 - padding: 32px 20px 64px;
5 -}
6 -
7 -.subtitle {
8 - color: var(--text);
9 - margin-bottom: 32px;
5 + padding: 28px 20px 72px;
10 6 }
11 7
12 8 .muted {
13 - color: var(--text);
14 - opacity: 0.7;
9 + color: var(--ink-soft);
15 10 }
16 11
17 12 .error {
18 13 color: var(--danger);
19 14 margin-top: 12px;
15 + font-size: 14px;
16 + text-align: center;
20 17 }
21 18
22 -/* Session list screen */
19 +/* ---------- Home ---------- */
23 20
24 -.create-session-form {
21 +.home-screen {
22 + padding-top: 64px;
23 +}
24 +
25 +.hero {
25 26 display: flex;
26 27 flex-direction: column;
27 - gap: 12px;
28 - padding: 20px;
29 - border: 1px solid var(--border);
30 - border-radius: 10px;
31 - margin-bottom: 32px;
32 - background: var(--bg-alt);
28 + align-items: center;
29 + text-align: center;
30 + margin-bottom: 44px;
31 + animation: rise 0.7s ease both;
32 +}
33 +
34 +.hero .orb-wrap {
35 + transform: scale(0.62);
36 + margin: -28px 0 -20px;
37 +}
38 +
39 +.wordmark {
40 + font-family: var(--serif);
41 + font-size: 44px;
42 + font-weight: 500;
43 + letter-spacing: -0.02em;
44 + margin: 0 0 6px;
45 +}
46 +
47 +.wordmark-dot {
48 + color: var(--clay);
33 49 }
34 50
35 -.create-session-form label {
51 +.tagline {
52 + color: var(--ink-soft);
53 + font-size: 16px;
54 + max-width: 380px;
55 +}
56 +
57 +.create-form {
36 58 display: flex;
37 59 flex-direction: column;
38 - gap: 4px;
39 - font-size: 14px;
40 - color: var(--text);
60 + gap: 22px;
61 + max-width: 400px;
62 + margin: 0 auto 56px;
63 + animation: rise 0.7s 0.12s ease both;
41 64 }
42 65
43 -.create-session-form input {
44 - padding: 8px 10px;
45 - border: 1px solid var(--border);
46 - border-radius: 6px;
47 - background: var(--bg);
48 - color: var(--text-h);
66 +.create-form label {
67 + display: flex;
68 + flex-direction: column;
69 + gap: 6px;
49 70 }
50 71
51 -.create-session-form button {
52 - align-self: flex-start;
53 - padding: 8px 16px;
72 +.create-form label span {
73 + font-size: 12px;
74 + text-transform: uppercase;
75 + letter-spacing: 0.1em;
76 + color: var(--ink-soft);
77 +}
78 +
79 +.create-form input {
80 + padding: 10px 2px;
54 81 border: none;
55 - border-radius: 6px;
56 - background: var(--accent);
57 - color: white;
82 + border-bottom: 1.5px solid var(--line);
83 + background: transparent;
84 + color: var(--ink);
85 + font-size: 17px;
86 + transition: border-color 0.25s ease;
87 +}
88 +
89 +.create-form input:focus {
90 + outline: none;
91 + border-bottom-color: var(--clay);
92 +}
93 +
94 +.create-form input::placeholder {
95 + color: var(--ink-soft);
96 + opacity: 0.55;
97 +}
98 +
99 +.btn-primary {
100 + align-self: center;
101 + padding: 11px 28px;
102 + border: none;
103 + border-radius: 999px;
104 + background: var(--clay);
105 + color: var(--card);
106 + font-size: 15px;
107 + letter-spacing: 0.02em;
58 108 cursor: pointer;
109 + transition: background 0.25s ease, transform 0.2s ease, box-shadow 0.25s ease;
110 + box-shadow: 0 6px 18px -8px rgba(196, 106, 74, 0.55);
59 111 }
60 112
61 -.create-session-form button:disabled {
62 - opacity: 0.6;
113 +.btn-primary:hover:not(:disabled) {
114 + background: var(--clay-deep);
115 + transform: translateY(-1px);
116 + box-shadow: 0 10px 22px -8px rgba(196, 106, 74, 0.6);
117 +}
118 +
119 +.btn-primary:disabled {
120 + opacity: 0.55;
63 121 cursor: default;
64 122 }
65 123
@@ -72,20 +130,30 @@
72 130 flex: 1;
73 131 }
74 132
75 -.folder-field button {
76 - align-self: auto;
77 - background: var(--bg);
78 - color: var(--text-h);
79 - border: 1px solid var(--border);
133 +.browse-button {
134 + align-self: center;
135 + padding: 9px 16px;
136 + border-radius: 999px;
137 + border: 1.5px solid var(--line);
138 + background: transparent;
139 + color: var(--ink);
140 + cursor: pointer;
80 141 white-space: nowrap;
142 + font-size: 14px;
143 + transition: background 0.25s ease, border-color 0.25s ease;
144 +}
145 +
146 +.browse-button:hover {
147 + background: var(--paper-deep);
148 + border-color: var(--clay);
81 149 }
82 150
83 151 /* Folder browser modal */
84 152
85 153 .folder-browser-overlay {
86 154 position: fixed;
87 155 inset: 0;
88 - background: rgba(0, 0, 0, 0.4);
156 + background: rgba(44, 38, 32, 0.45);
89 157 display: flex;
90 158 align-items: center;
91 159 justify-content: center;
@@ -94,25 +162,29 @@
94 162 }
95 163
96 164 .folder-browser {
97 - background: var(--bg);
98 - border-radius: 10px;
99 - padding: 20px;
165 + background: var(--card);
166 + border-radius: 16px;
167 + padding: 24px;
100 168 width: 100%;
101 169 max-width: 480px;
102 170 max-height: 80vh;
103 171 display: flex;
104 172 flex-direction: column;
105 - gap: 12px;
173 + gap: 14px;
174 + box-shadow: 0 24px 60px -24px rgba(44, 38, 32, 0.5);
175 +}
176 +
177 +.folder-browser h2 {
178 + font-size: 19px;
106 179 }
107 180
108 181 .folder-browser-path {
109 182 font-size: 13px;
110 - color: var(--text);
111 - opacity: 0.8;
183 + color: var(--ink-soft);
112 184 word-break: break-all;
113 - background: var(--bg-alt);
114 - padding: 6px 10px;
115 - border-radius: 6px;
185 + background: var(--paper-deep);
186 + padding: 8px 10px;
187 + border-radius: 8px;
116 188 }
117 189
118 190 .folder-browser-list {
@@ -121,8 +193,8 @@
121 193 display: flex;
122 194 flex-direction: column;
123 195 gap: 4px;
124 - border: 1px solid var(--border);
125 - border-radius: 8px;
196 + border: 1px solid var(--line);
197 + border-radius: 10px;
126 198 padding: 6px;
127 199 }
128 200
@@ -131,13 +203,14 @@
131 203 padding: 8px 10px;
132 204 border: none;
133 205 background: none;
134 - border-radius: 6px;
206 + border-radius: 8px;
135 207 cursor: pointer;
136 - color: var(--text-h);
208 + color: var(--ink);
209 + font-size: 14px;
137 210 }
138 211
139 212 .folder-browser-entry:hover {
140 - background: var(--bg-alt);
213 + background: var(--paper-deep);
141 214 }
142 215
143 216 .folder-browser-new {
@@ -148,10 +221,10 @@
148 221 .folder-browser-new input {
149 222 flex: 1;
150 223 padding: 8px 10px;
151 - border: 1px solid var(--border);
152 - border-radius: 6px;
153 - background: var(--bg);
154 - color: var(--text-h);
224 + border: 1.5px solid var(--line);
225 + border-radius: 8px;
226 + background: transparent;
227 + color: var(--ink);
155 228 }
156 229
157 230 .folder-browser-actions {
@@ -160,90 +233,159 @@
160 233 gap: 8px;
161 234 }
162 235
163 -.folder-browser button {
164 - padding: 8px 14px;
165 - border-radius: 6px;
166 - border: 1px solid var(--border);
167 - background: var(--bg-alt);
168 - color: var(--text-h);
236 +.folder-browser-actions button,
237 +.folder-browser-new button {
238 + padding: 8px 16px;
239 + border-radius: 999px;
240 + border: 1.5px solid var(--line);
241 + background: transparent;
242 + color: var(--ink);
169 243 cursor: pointer;
244 + font-size: 14px;
245 + transition: background 0.25s ease, border-color 0.25s ease;
170 246 }
171 247
172 -.folder-browser button.primary {
173 - background: var(--accent);
174 - color: white;
175 - border-color: var(--accent);
248 +.folder-browser-actions button:hover:not(:disabled),
249 +.folder-browser-new button:hover:not(:disabled) {
250 + background: var(--paper-deep);
251 + border-color: var(--clay);
252 +}
253 +
254 +.folder-browser-actions button.primary {
255 + background: var(--clay);
256 + color: var(--card);
257 + border-color: var(--clay);
258 +}
259 +
260 +.folder-browser-actions button.primary:hover:not(:disabled) {
261 + background: var(--clay-deep);
176 262 }
177 263
178 264 .folder-browser button:disabled {
179 - opacity: 0.6;
265 + opacity: 0.55;
180 266 cursor: default;
181 267 }
182 268
269 +.session-list {
270 + max-width: 400px;
271 + margin: 0 auto;
272 + animation: rise 0.7s 0.22s ease both;
273 +}
274 +
275 +.session-list h2 {
276 + font-size: 13px;
277 + font-family: var(--sans);
278 + font-weight: 500;
279 + text-transform: uppercase;
280 + letter-spacing: 0.12em;
281 + color: var(--ink-soft);
282 + margin-bottom: 10px;
283 +}
183 284 .session-list ul {
184 285 list-style: none;
185 286 padding: 0;
186 - margin: 12px 0 0;
287 + margin: 0;
187 288 display: flex;
188 289 flex-direction: column;
189 - gap: 8px;
290 +}
291 +
292 +.session-row {
293 + display: flex;
294 + align-items: center;
295 + gap: 4px;
296 +}
297 +
298 +.session-row .session-item {
299 + flex: 1;
300 +}
301 +
302 +.session-delete {
303 + border: none;
304 + background: none;
305 + color: var(--ink-soft);
306 + font-size: 18px;
307 + line-height: 1;
308 + padding: 6px 8px;
309 + cursor: pointer;
310 + opacity: 0;
311 + transition: opacity 0.2s ease, color 0.2s ease;
312 +}
313 +
314 +.session-row:hover .session-delete,
315 +.session-delete:focus-visible {
316 + opacity: 1;
317 +}
318 +
319 +.session-delete:hover {
320 + color: var(--danger);
321 +}
322 +
323 +.session-meta {
324 + display: flex;
325 + align-items: center;
326 + gap: 12px;
327 +}
328 +
329 +.session-date {
330 + font-size: 12px;
331 + color: var(--ink-soft);
190 332 }
191 333
192 334 .session-item {
193 335 width: 100%;
194 336 display: flex;
195 337 justify-content: space-between;
196 338 align-items: center;
197 - padding: 12px 16px;
198 - border: 1px solid var(--border);
199 - border-radius: 8px;
200 - background: var(--bg);
339 + padding: 13px 4px;
340 + border: none;
341 + border-bottom: 1px solid var(--line);
342 + background: transparent;
201 343 cursor: pointer;
202 - color: var(--text-h);
344 + color: var(--ink);
203 345 text-align: left;
346 + font-size: 16px;
347 + transition: padding 0.25s ease;
204 348 }
205 349
206 350 .session-item:hover {
207 - border-color: var(--accent);
351 + padding-left: 10px;
352 +}
353 +
354 +.session-item:hover .session-name {
355 + color: var(--clay-deep);
356 +}
357 +
358 +.session-name {
359 + transition: color 0.25s ease;
208 360 }
209 361
210 362 .session-status {
211 - font-size: 12px;
212 - padding: 2px 8px;
213 - border-radius: 999px;
214 - background: var(--bg-alt);
363 + font-size: 11px;
215 364 text-transform: uppercase;
216 - letter-spacing: 0.04em;
365 + letter-spacing: 0.1em;
366 + color: var(--ink-soft);
217 367 }
218 368
219 369 .session-status.status-done {
220 - color: var(--accent);
370 + color: var(--moss);
221 371 }
222 372
223 -/* Interview screen */
373 +/* ---------- Topbar ---------- */
224 374
225 -.interview-header {
375 +.topbar {
226 376 display: flex;
227 - justify-content: space-between;
228 377 align-items: center;
229 378 gap: 16px;
230 379 flex-wrap: wrap;
380 + margin-bottom: 22px;
231 381 }
232 382
233 -.interview-header-actions {
383 +.topbar-actions {
234 384 display: flex;
235 385 align-items: center;
236 386 gap: 16px;
237 387 flex-wrap: wrap;
238 -}
239 -
240 -.tts-toggle {
241 - display: flex;
242 - align-items: center;
243 - gap: 6px;
244 - font-size: 14px;
245 - color: var(--text);
246 - white-space: nowrap;
388 + margin-left: auto;
247 389 }
248 390
249 391 .transcript-export {
@@ -253,304 +395,703 @@
253 395 }
254 396
255 397 .transcript-export button {
256 - padding: 6px 10px;
257 - border-radius: 6px;
258 - border: 1px solid var(--border);
259 - background: var(--bg-alt);
260 - color: var(--text-h);
398 + padding: 6px 12px;
399 + border-radius: 999px;
400 + border: 1.5px solid var(--line);
401 + background: transparent;
402 + color: var(--ink);
261 403 cursor: pointer;
262 404 font-size: 13px;
405 + transition: background 0.25s ease, border-color 0.25s ease;
406 +}
407 +
408 +.transcript-export button:hover {
409 + background: var(--paper-deep);
410 + border-color: var(--clay);
263 411 }
264 412
265 413 .download-link {
266 - padding: 6px 10px;
267 - border-radius: 6px;
268 - border: 1px solid var(--border);
269 - background: var(--bg-alt);
270 - color: var(--text-h);
414 + padding: 6px 12px;
415 + border-radius: 999px;
416 + border: 1.5px solid var(--line);
417 + background: transparent;
418 + color: var(--ink);
271 419 font-size: 13px;
272 420 text-decoration: none;
273 421 display: inline-block;
422 + transition: background 0.25s ease, border-color 0.25s ease;
274 423 }
275 424
276 425 .download-link:hover {
277 - border-color: var(--accent);
426 + background: var(--paper-deep);
427 + border-color: var(--clay);
278 428 }
279 429
280 -.push-to-talk {
281 - margin-bottom: 12px;
430 +.back-link {
431 + background: none;
432 + border: none;
433 + color: var(--ink-soft);
434 + cursor: pointer;
435 + padding: 0;
436 + font-size: 14px;
437 + transition: color 0.2s ease;
282 438 }
283 439
284 -.record-button {
285 - width: 100%;
286 - padding: 14px;
287 - border-radius: 8px;
288 - border: 1px solid var(--border);
289 - background: var(--bg-alt);
290 - color: var(--text-h);
440 +.back-link:hover {
441 + color: var(--clay-deep);
442 +}
443 +
444 +.topbar-title {
445 + flex: 1;
446 + text-align: center;
447 + font-family: var(--serif);
448 + font-size: 17px;
449 + color: var(--ink);
450 + overflow: hidden;
451 + text-overflow: ellipsis;
452 + white-space: nowrap;
453 +}
454 +
455 +.tts-toggle {
456 + display: flex;
457 + align-items: center;
458 + gap: 8px;
459 + font-size: 13px;
460 + color: var(--ink-soft);
291 461 cursor: pointer;
292 - font-size: 15px;
462 + user-select: none;
293 463 }
294 464
295 -.record-button.recording {
296 - background: var(--danger);
297 - color: white;
298 - border-color: var(--danger);
465 +.tts-toggle input {
466 + position: absolute;
467 + opacity: 0;
468 + pointer-events: none;
299 469 }
300 470
301 -.record-button:disabled {
302 - opacity: 0.6;
303 - cursor: default;
471 +.tts-track {
472 + width: 34px;
473 + height: 20px;
474 + border-radius: 999px;
475 + background: var(--line);
476 + position: relative;
477 + transition: background 0.25s ease;
478 + flex-shrink: 0;
304 479 }
305 480
306 -.back-link {
307 - background: none;
308 - border: none;
309 - color: var(--accent);
310 - cursor: pointer;
311 - padding: 0;
312 - margin-bottom: 16px;
313 - font-size: 14px;
481 +.tts-track::after {
482 + content: '';
483 + position: absolute;
484 + top: 3px;
485 + left: 3px;
486 + width: 14px;
487 + height: 14px;
488 + border-radius: 50%;
489 + background: var(--card);
490 + box-shadow: 0 1px 3px rgba(44, 38, 32, 0.25);
491 + transition: transform 0.25s cubic-bezier(0.34, 1.4, 0.64, 1);
314 492 }
315 493
316 -.interview-layout {
317 - display: grid;
318 - grid-template-columns: 1fr 220px;
319 - gap: 24px;
320 - align-items: start;
494 +.tts-toggle input:checked + .tts-track {
495 + background: var(--clay);
321 496 }
322 497
323 -@media (max-width: 720px) {
324 - .interview-layout {
325 - grid-template-columns: 1fr;
326 - }
498 +.tts-toggle input:checked + .tts-track::after {
499 + transform: translateX(14px);
327 500 }
328 501
329 -.question-card {
330 - padding: 16px 20px;
331 - border-radius: 10px;
332 - background: var(--accent-bg);
333 - margin-bottom: 20px;
502 +/* ---------- Coverage strip ---------- */
503 +
504 +.coverage-progress {
505 + font-size: 13px;
506 + color: var(--ink-soft);
507 + text-align: center;
508 + margin: 0 0 10px;
334 509 }
335 510
336 -.question-label {
337 - display: block;
511 +.coverage-panel {
512 + list-style: none;
513 + padding: 0;
514 + margin: 0 0 30px;
515 + display: flex;
516 + flex-wrap: wrap;
517 + justify-content: center;
518 + gap: 6px 14px;
519 +}
520 +
521 +.coverage-item {
522 + display: flex;
523 + align-items: center;
524 + gap: 6px;
338 525 font-size: 12px;
339 - text-transform: uppercase;
340 - letter-spacing: 0.06em;
341 - color: var(--accent);
342 - margin-bottom: 6px;
526 + letter-spacing: 0.04em;
527 + color: var(--ink-soft);
528 + transition: color 0.4s ease;
529 +}
530 +
531 +.coverage-clear {
532 + color: var(--ink);
533 +}
534 +
535 +.coverage-dot {
536 + width: 7px;
537 + height: 7px;
538 + border-radius: 50%;
539 + background: transparent;
540 + border: 1.5px solid var(--line);
541 + flex-shrink: 0;
542 + transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s ease;
543 +}
544 +
545 +.coverage-partial .coverage-dot {
546 + border-color: var(--amber);
547 + background: color-mix(in srgb, var(--amber) 45%, transparent);
548 +}
549 +
550 +.coverage-clear .coverage-dot {
551 + border-color: var(--moss);
552 + background: var(--moss);
553 + transform: scale(1.1);
554 +}
555 +
556 +/* ---------- Stage ---------- */
557 +
558 +.stage {
559 + display: flex;
560 + flex-direction: column;
561 + align-items: center;
562 + text-align: center;
563 +}
564 +
565 +.question {
566 + max-width: 520px;
567 + margin: 26px 0 8px;
568 + animation: rise 0.5s ease both;
343 569 }
344 570
345 571 .question-text {
346 - font-size: 18px;
347 - color: var(--text-h);
572 + font-family: var(--serif);
573 + font-size: 26px;
574 + line-height: 1.35;
575 + font-weight: 450;
576 + color: var(--ink);
577 + text-wrap: balance;
348 578 }
349 579
350 -.transcript {
580 +.status-caption {
581 + font-size: 13px;
582 + letter-spacing: 0.08em;
583 + color: var(--ink-soft);
584 + margin: 4px 0 18px;
585 + min-height: 20px;
586 +}
587 +
588 +/* ---------- Orb ---------- */
589 +
590 +.orb-wrap {
591 + position: relative;
592 + width: 148px;
593 + height: 148px;
594 + margin-top: 10px;
595 + flex-shrink: 0;
596 +}
597 +
598 +.orb {
599 + position: absolute;
600 + inset: 0;
601 + border-radius: 50%;
602 + background:
603 + radial-gradient(circle at 33% 28%, #f9ddbd 0%, #eeb289 42%, #cd7a54 78%, #b4593a 100%);
604 + box-shadow:
605 + inset -8px -12px 24px rgba(140, 60, 30, 0.28),
606 + inset 6px 8px 18px rgba(255, 245, 225, 0.55),
607 + 0 18px 40px -18px rgba(140, 70, 40, 0.5);
608 + animation: breathe 5.2s ease-in-out infinite;
609 +}
610 +
611 +.orb-face {
612 + position: absolute;
613 + inset: 0;
351 614 display: flex;
352 615 flex-direction: column;
353 - gap: 12px;
354 - margin-bottom: 20px;
616 + align-items: center;
617 + justify-content: center;
618 + gap: 10px;
355 619 }
356 620
357 -.transcript-empty {
358 - color: var(--text);
359 - opacity: 0.7;
360 - margin-bottom: 20px;
621 +.orb-eyes {
622 + display: flex;
623 + gap: 26px;
361 624 }
362 625
363 -.segment {
364 - padding: 10px 14px;
365 - border-radius: 8px;
366 - border: 1px solid var(--border);
626 +.orb-eye {
627 + width: 9px;
628 + height: 22px;
629 + border-radius: 6px;
630 + background: #38261c;
631 + animation: blink 5.4s ease-in-out infinite;
632 + transition: transform 0.3s ease;
367 633 }
368 634
369 -.segment-user {
370 - background: var(--bg);
635 +.orb-mouth {
636 + display: flex;
637 + align-items: center;
638 + gap: 3px;
639 + height: 14px;
640 + opacity: 0;
641 + transition: opacity 0.25s ease;
371 642 }
372 643
373 -.segment-interviewer {
374 - background: var(--bg-alt);
644 +.orb-mouth-bar {
645 + width: 3.5px;
646 + height: 4px;
647 + border-radius: 2px;
648 + background: #38261c;
375 649 }
376 650
377 -.segment-speaker {
378 - display: block;
651 +/* level ring, driven by mic input via JS */
652 +.orb-level-ring {
653 + position: absolute;
654 + inset: -14px;
655 + border-radius: 50%;
656 + border: 2px solid var(--clay);
657 + opacity: 0;
658 + transition: opacity 0.3s ease;
659 + will-change: transform, opacity;
660 +}
661 +
662 +.orb-ripple {
663 + position: absolute;
664 + inset: -6px;
665 + border-radius: 50%;
666 + border: 1.5px solid var(--clay);
667 + opacity: 0;
668 +}
669 +
670 +/* states */
671 +
672 +.orb-listening .orb-ripple-1 {
673 + animation: ripple 1.9s ease-out infinite;
674 +}
675 +
676 +.orb-listening .orb-ripple-2 {
677 + animation: ripple 1.9s 0.65s ease-out infinite;
678 +}
679 +
680 +.orb-listening .orb-eye {
681 + transform: scaleY(1.12);
682 +}
683 +
684 +.orb-thinking .orb {
685 + animation: bob 1.6s ease-in-out infinite;
686 +}
687 +
688 +.orb-thinking .orb-eye {
689 + transform: scaleY(0.22) translateY(6px);
690 + animation: none;
691 +}
692 +
693 +.orb-speaking .orb-mouth {
694 + opacity: 1;
695 +}
696 +
697 +.orb-speaking .orb-mouth-bar {
698 + animation: talk 0.55s ease-in-out infinite;
699 +}
700 +
701 +.orb-speaking .orb-mouth-bar:nth-child(2) {
702 + animation-delay: 0.12s;
703 +}
704 +
705 +.orb-speaking .orb-mouth-bar:nth-child(3) {
706 + animation-delay: 0.24s;
707 +}
708 +
709 +.orb-speaking .orb-mouth-bar:nth-child(4) {
710 + animation-delay: 0.36s;
711 +}
712 +
713 +/* ---------- Waveform ---------- */
714 +
715 +.waveform {
716 + display: flex;
717 + align-items: center;
718 + justify-content: center;
719 + gap: 3px;
720 + height: 44px;
721 + margin-top: 14px;
722 +}
723 +
724 +.waveform-bar {
725 + width: 2.5px;
726 + height: 38px;
727 + border-radius: 2px;
728 + background: var(--clay);
729 + transform: scaleY(0.08);
730 + transform-origin: center;
731 + transition: background 0.3s ease, opacity 0.3s ease;
732 + will-change: transform;
733 +}
734 +
735 +.waveform-idle .waveform-bar {
736 + opacity: 0.28;
737 + background: var(--ink-soft);
738 +}
739 +
740 +.waveform-simulated .waveform-bar {
741 + animation: wave 0.8s ease-in-out infinite alternate;
742 +}
743 +
744 +/* ---------- Mic + answer ---------- */
745 +
746 +.push-to-talk {
747 + display: flex;
748 + flex-direction: column;
749 + align-items: center;
750 + gap: 8px;
751 + margin-bottom: 18px;
752 +}
753 +
754 +.mic-button {
755 + width: 72px;
756 + height: 72px;
757 + border-radius: 50%;
758 + border: 1.5px solid var(--line);
759 + background: var(--card);
760 + color: var(--ink);
761 + cursor: pointer;
762 + display: flex;
763 + align-items: center;
764 + justify-content: center;
765 + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease,
766 + transform 0.2s ease, box-shadow 0.3s ease;
767 + box-shadow: 0 10px 24px -14px rgba(44, 38, 32, 0.4);
768 + touch-action: none;
769 +}
770 +
771 +.mic-button:hover:not(:disabled) {
772 + border-color: var(--clay);
773 + color: var(--clay-deep);
774 + transform: translateY(-1px);
775 +}
776 +
777 +.mic-button.recording {
778 + background: var(--clay);
779 + border-color: var(--clay);
780 + color: var(--card);
781 + transform: scale(1.08);
782 + box-shadow: 0 0 0 10px rgba(196, 106, 74, 0.16), 0 14px 30px -14px rgba(169, 85, 56, 0.7);
783 +}
784 +
785 +.mic-button:disabled {
786 + opacity: 0.5;
787 + cursor: default;
788 +}
789 +
790 +.mic-hint {
379 791 font-size: 12px;
380 - color: var(--text);
381 - opacity: 0.7;
382 - margin-bottom: 4px;
792 + letter-spacing: 0.06em;
793 + color: var(--ink-soft);
383 794 }
384 795
385 796 .answer-form {
386 797 display: flex;
387 - gap: 8px;
798 + gap: 10px;
799 + width: 100%;
800 + max-width: 440px;
388 801 }
389 802
390 803 .answer-form input {
391 804 flex: 1;
392 - padding: 10px 12px;
393 - border: 1px solid var(--border);
394 - border-radius: 6px;
395 - background: var(--bg);
396 - color: var(--text-h);
805 + padding: 11px 16px;
806 + border: 1.5px solid var(--line);
807 + border-radius: 999px;
808 + background: var(--card);
809 + color: var(--ink);
810 + font-size: 15px;
811 + transition: border-color 0.25s ease;
812 +}
813 +
814 +.answer-form input:focus {
815 + outline: none;
816 + border-color: var(--clay);
817 +}
818 +
819 +.answer-form input::placeholder {
820 + color: var(--ink-soft);
821 + opacity: 0.6;
397 822 }
398 823
399 -.answer-form button {
400 - padding: 10px 16px;
824 +.done-button {
825 + margin-top: 14px;
826 + background: none;
401 827 border: none;
402 - border-radius: 6px;
828 + color: var(--ink-soft);
829 + font-size: 13px;
830 + letter-spacing: 0.04em;
403 831 cursor: pointer;
832 + text-decoration: underline;
833 + text-underline-offset: 4px;
834 + text-decoration-color: var(--line);
835 + transition: color 0.25s ease, text-decoration-color 0.25s ease;
404 836 }
405 837
406 -.answer-form button[type='submit'] {
407 - background: var(--accent);
408 - color: white;
838 +.done-button:hover:not(:disabled) {
839 + color: var(--clay-deep);
840 + text-decoration-color: var(--clay);
409 841 }
410 842
411 -.answer-form .done-button {
412 - background: var(--bg-alt);
413 - color: var(--text-h);
414 - border: 1px solid var(--border);
843 +.done-button:disabled {
844 + opacity: 0.5;
845 + cursor: default;
415 846 }
416 847
417 -.answer-form button:disabled {
418 - opacity: 0.6;
419 - cursor: default;
848 +/* ---------- Finish panels ---------- */
849 +
850 +.interview-done {
851 + font-family: var(--serif);
852 + font-size: 24px;
853 + margin: 24px 0 6px;
420 854 }
421 855
422 -.generate-panel {
423 - margin-top: 20px;
424 - padding: 16px 20px;
425 - border: 1px solid var(--border);
426 - border-radius: 10px;
856 +.finish-panels {
857 + display: flex;
858 + flex-direction: column;
859 + gap: 16px;
860 + width: 100%;
861 + max-width: 460px;
862 + margin-top: 18px;
863 + text-align: left;
427 864 }
428 865
429 -.generate-panel h2 {
430 - font-size: 16px;
431 - margin-bottom: 12px;
866 +.generate-panel,
867 +.blocker-import {
868 + padding: 20px 22px;
869 + border: 1px solid var(--line);
870 + border-radius: 16px;
871 + background: var(--card);
872 + box-shadow: 0 12px 30px -24px rgba(44, 38, 32, 0.5);
432 873 }
433 874
434 -.generate-panel button {
435 - padding: 8px 14px;
436 - border-radius: 6px;
875 +.generate-panel h2,
876 +.blocker-import h2 {
877 + font-size: 17px;
878 + margin-bottom: 10px;
879 +}
880 +
881 +.generate-panel p,
882 +.blocker-import p {
883 + font-size: 14px;
884 + color: var(--ink-soft);
885 + margin-bottom: 10px;
886 +}
887 +
888 +.generate-panel button,
889 +.blocker-import button {
890 + padding: 9px 18px;
891 + border-radius: 999px;
437 892 border: none;
438 - background: var(--accent);
439 - color: white;
893 + background: var(--clay);
894 + color: var(--card);
895 + font-size: 14px;
440 896 cursor: pointer;
441 897 margin-right: 8px;
898 + transition: background 0.25s ease;
442 899 }
443 900
444 -.generate-panel button:disabled {
445 - opacity: 0.6;
446 - cursor: default;
901 +.generate-panel button:hover:not(:disabled),
902 +.blocker-import button:hover:not(:disabled) {
903 + background: var(--clay-deep);
447 904 }
448 905
449 -.generate-confirm,
450 -.generate-overwrite {
451 - margin-bottom: 12px;
906 +.generate-panel button:disabled,
907 +.blocker-import button:disabled {
908 + opacity: 0.55;
909 + cursor: default;
452 910 }
453 911
454 912 .generate-confirm button:last-child,
455 -.generate-overwrite button {
456 - background: var(--bg-alt);
457 - color: var(--text-h);
458 - border: 1px solid var(--border);
913 +.generate-overwrite button,
914 +.blocker-import button {
915 + background: transparent;
916 + color: var(--ink);
917 + border: 1.5px solid var(--line);
918 +}
919 +
920 +.generate-confirm button:last-child:hover:not(:disabled),
921 +.generate-overwrite button:hover:not(:disabled),
922 +.blocker-import button:hover:not(:disabled) {
923 + background: var(--paper-deep);
924 + border-color: var(--clay);
459 925 }
460 926
461 927 .generate-result ul {
462 - margin: 8px 0 12px;
928 + margin: 8px 0 0;
463 929 padding-left: 20px;
930 + font-size: 14px;
931 + color: var(--ink);
464 932 }
465 933
466 934 .generate-warnings {
467 935 margin-top: 8px;
468 - color: var(--text);
469 - opacity: 0.85;
936 + font-size: 14px;
937 + color: var(--ink-soft);
470 938 }
471 939
472 -.blocker-import {
473 - margin-top: 20px;
474 - padding: 16px 20px;
475 - border: 1px solid var(--border);
476 - border-radius: 10px;
940 +.generate-panel .error,
941 +.blocker-import .error {
942 + text-align: left;
477 943 }
478 944
479 -.blocker-import h2 {
480 - font-size: 16px;
481 - margin-bottom: 8px;
945 +/* ---------- Transcript ---------- */
946 +
947 +.transcript-section {
948 + margin-top: 46px;
949 + animation: rise 0.6s ease both;
482 950 }
483 951
484 -.blocker-import button {
485 - padding: 8px 14px;
486 - border-radius: 6px;
487 - border: 1px solid var(--border);
488 - background: var(--bg-alt);
489 - color: var(--text-h);
490 - cursor: pointer;
491 - margin-top: 4px;
952 +.transcript-title {
953 + font-size: 12px;
954 + font-family: var(--sans);
955 + font-weight: 500;
956 + text-transform: uppercase;
957 + letter-spacing: 0.14em;
958 + color: var(--ink-soft);
959 + margin-bottom: 12px;
492 960 }
493 961
494 -.blocker-import button:disabled {
495 - opacity: 0.6;
496 - cursor: default;
962 +.transcript {
963 + display: flex;
964 + flex-direction: column;
965 + gap: 14px;
966 + max-height: 300px;
967 + overflow-y: auto;
968 + padding-right: 8px;
969 + mask-image: linear-gradient(to bottom, transparent 0, black 18px);
497 970 }
498 971
499 -.interview-done {
500 - padding: 12px 16px;
501 - border-radius: 8px;
502 - background: var(--accent-bg);
503 - color: var(--text-h);
972 +.segment {
973 + max-width: 82%;
974 + animation: rise 0.35s ease both;
504 975 }
505 976
506 -.interview-sidebar h2 {
507 - font-size: 14px;
977 +.segment-user {
978 + align-self: flex-end;
979 + text-align: right;
980 +}
981 +
982 +.segment-interviewer {
983 + align-self: flex-start;
984 + border-left: 2px solid var(--clay-soft);
985 + padding-left: 12px;
986 +}
987 +
988 +.segment-speaker {
989 + display: block;
990 + font-size: 11px;
508 991 text-transform: uppercase;
509 - letter-spacing: 0.06em;
510 - color: var(--text);
511 - opacity: 0.8;
992 + letter-spacing: 0.1em;
993 + color: var(--ink-soft);
994 + margin-bottom: 3px;
512 995 }
513 996
514 -.coverage-progress {
515 - font-size: 13px;
516 - color: var(--text);
517 - opacity: 0.8;
518 - margin: 0 0 10px;
997 +.segment-text {
998 + font-size: 15px;
999 + color: var(--ink);
519 1000 }
520 1001
521 -.coverage-panel {
522 - list-style: none;
523 - padding: 0;
524 - margin: 0;
525 - display: flex;
526 - flex-direction: column;
527 - gap: 8px;
1002 +.segment-user .segment-text {
1003 + display: inline-block;
1004 + background: var(--paper-deep);
1005 + border-radius: 14px 14px 4px 14px;
1006 + padding: 8px 14px;
528 1007 }
529 1008
530 -.coverage-item {
531 - display: flex;
532 - align-items: center;
533 - gap: 8px;
534 - font-size: 14px;
535 - color: var(--text-h);
1009 +/* ---------- Animations ---------- */
1010 +
1011 +@keyframes breathe {
1012 + 0%,
1013 + 100% {
1014 + transform: scale(1);
1015 + }
1016 + 50% {
1017 + transform: scale(1.035);
1018 + }
536 1019 }
537 1020
538 -.coverage-dot {
539 - width: 10px;
540 - height: 10px;
541 - border-radius: 50%;
542 - background: var(--border);
543 - flex-shrink: 0;
1021 +@keyframes blink {
1022 + 0%,
1023 + 4%,
1024 + 100% {
1025 + transform: scaleY(1);
1026 + }
1027 + 2% {
1028 + transform: scaleY(0.08);
1029 + }
544 1030 }
545 1031
546 -.coverage-missing .coverage-dot {
547 - background: var(--border);
1032 +@keyframes ripple {
1033 + 0% {
1034 + transform: scale(1);
1035 + opacity: 0.45;
1036 + }
1037 + 100% {
1038 + transform: scale(1.42);
1039 + opacity: 0;
1040 + }
548 1041 }
549 1042
550 -.coverage-partial .coverage-dot {
551 - background: #e0a83f;
1043 +@keyframes bob {
1044 + 0%,
1045 + 100% {
1046 + transform: translateY(0) scale(1);
1047 + }
1048 + 50% {
1049 + transform: translateY(-4px) scale(1.015);
1050 + }
552 1051 }
553 1052
554 -.coverage-clear .coverage-dot {
555 - background: #2fa869;
1053 +@keyframes talk {
1054 + 0%,
1055 + 100% {
1056 + height: 4px;
1057 + }
1058 + 50% {
1059 + height: 13px;
1060 + }
1061 +}
1062 +
1063 +@keyframes wave {
1064 + 0% {
1065 + transform: scaleY(0.12);
1066 + }
1067 + 100% {
1068 + transform: scaleY(0.85);
1069 + }
1070 +}
1071 +
1072 +@keyframes rise {
1073 + from {
1074 + opacity: 0;
1075 + transform: translateY(10px);
1076 + }
1077 + to {
1078 + opacity: 1;
1079 + transform: translateY(0);
1080 + }
1081 +}
1082 +
1083 +@media (prefers-reduced-motion: reduce) {
1084 + .orb,
1085 + .orb-eye,
1086 + .orb-ripple,
1087 + .orb-mouth-bar,
1088 + .waveform-bar,
1089 + .hero,
1090 + .create-form,
1091 + .session-list,
1092 + .question,
1093 + .segment,
1094 + .transcript-section {
1095 + animation: none !important;
1096 + }
556 1097 }
modified client/src/App.tsx +180 −90
@@ -1,4 +1,4 @@
1 -import { useEffect, useRef, useState } from 'react'
1 +import { useCallback, useEffect, useRef, useState } from 'react'
2 2 import type { Session, SessionSummary } from 'shared/types'
3 3 import * as api from './api'
4 4 import './App.css'
@@ -10,12 +10,14 @@import { PushToTalkButton } from './components/PushToTalkButton'
10 10 import { QuestionCard } from './components/QuestionCard'
11 11 import { Transcript } from './components/Transcript'
12 12 import { TranscriptExport } from './components/TranscriptExport'
13 -import { speak } from './tts'
13 +import { VoiceOrb, type VoiceState } from './components/VoiceOrb'
14 +import { Waveform, type WaveformMode } from './components/Waveform'
15 +import { speak, stopSpeaking } from './tts'
14 16
15 17 function latestQuestion(session: Session): string | null {
16 18 for (let i = session.segments.length - 1; i >= 0; i--) {
17 19 const segment = session.segments[i]
18 - if (segment.speaker === 'interviewer') return segment.text
20 + if (segment && segment.speaker === 'interviewer') return segment.text
19 21 }
20 22 return null
21 23 }
@@ -24,6 +26,13 @@function statusLabel(status: Session['status']): string {
24 26 return status === 'done' ? 'Ready' : 'In progress'
25 27 }
26 28
29 +const CAPTIONS: Record<VoiceState, string> = {
30 + idle: 'hold the mic or press space to answer',
31 + listening: 'listening…',
32 + thinking: 'thinking…',
33 + speaking: 'speaking…',
34 +}
35 +
27 36 function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
28 37 const [sessions, setSessions] = useState<SessionSummary[]>([])
29 38 const [name, setName] = useState('')
@@ -36,6 +45,17 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
36 45 api.listSessions().then(setSessions).catch((err: Error) => setError(err.message))
37 46 }, [])
38 47
48 + async function handleDelete(id: string) {
49 + if (!window.confirm('Delete this session? The transcript is lost.')) return
50 + setError(null)
51 + try {
52 + await api.deleteSession(id)
53 + setSessions(await api.listSessions())
54 + } catch (err) {
55 + setError(err instanceof Error ? err.message : String(err))
56 + }
57 + }
58 +
39 59 async function handleCreate(event: React.FormEvent) {
40 60 event.preventDefault()
41 61 setError(null)
@@ -51,35 +71,36 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
51 71 }
52 72
53 73 return (
54 - <div className="screen session-list-screen">
55 - <h1>VoiceTask</h1>
56 - <p className="subtitle">
57 - Talk through what you want built. We'll ask questions one at a time, then turn it into a
58 - clear spec you can hand to whoever builds it — no technical experience needed.
59 - </p>
60 -
61 - <form className="create-session-form" onSubmit={handleCreate}>
62 - <h2>Tell us about your idea</h2>
74 + <div className="screen home-screen">
75 + <div className="hero">
76 + <VoiceOrb state="idle" />
77 + <h1 className="wordmark">
78 + voicetask<span className="wordmark-dot">.</span>
79 + </h1>
80 + <p className="tagline">Talk through what you want to build. It interviews you, then writes the spec.</p>
81 + </div>
82 +
83 + <form className="create-form" onSubmit={handleCreate}>
63 84 <label>
64 - What should we call this project?
65 - <input value={name} onChange={(e) => setName(e.target.value)} required />
85 + <span>Project name</span>
86 + <input value={name} onChange={(e) => setName(e.target.value)} placeholder="my next thing" required />
66 87 </label>
67 88 <label>
68 - Project folder
89 + <span>Project folder</span>
69 90 <div className="folder-field">
70 91 <input
71 92 value={targetDir}
72 93 onChange={(e) => setTargetDir(e.target.value)}
73 - placeholder="Where should we save this?"
94 + placeholder="where should we save this?"
74 95 required
75 96 />
76 - <button type="button" onClick={() => setBrowsingFolder(true)}>
97 + <button type="button" className="browse-button" onClick={() => setBrowsingFolder(true)}>
77 98 Browse…
78 99 </button>
79 100 </div>
80 101 </label>
81 - <button type="submit" disabled={creating}>
82 - {creating ? 'Getting started…' : 'Get started'}
102 + <button type="submit" className="btn-primary" disabled={creating}>
103 + {creating ? 'Starting…' : 'Start talking'}
83 104 </button>
84 105 </form>
85 106
@@ -95,20 +116,36 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
95 116
96 117 {error && <p className="error">{error}</p>}
97 118
98 - <div className="session-list">
99 - <h2>Your projects</h2>
100 - {sessions.length === 0 && <p className="muted">Nothing here yet — start your first project above.</p>}
101 - <ul>
102 - {sessions.map((session) => (
103 - <li key={session.id}>
104 - <button type="button" className="session-item" onClick={() => onOpen(session.id)}>
105 - <span className="session-name">{session.name}</span>
106 - <span className={`session-status status-${session.status}`}>{statusLabel(session.status)}</span>
107 - </button>
108 - </li>
109 - ))}
110 - </ul>
111 - </div>
119 + {sessions.length > 0 && (
120 + <div className="session-list">
121 + <h2>Earlier sessions</h2>
122 + <ul>
123 + {sessions.map((session) => (
124 + <li key={session.id} className="session-row">
125 + <button type="button" className="session-item" onClick={() => onOpen(session.id)}>
126 + <span className="session-name">{session.name}</span>
127 + <span className="session-meta">
128 + <span className="session-date">
129 + {new Date(session.createdAt).toLocaleDateString()}
130 + </span>
131 + <span className={`session-status status-${session.status}`}>
132 + {statusLabel(session.status)}
133 + </span>
134 + </span>
135 + </button>
136 + <button
137 + type="button"
138 + className="session-delete"
139 + aria-label={`Delete ${session.name}`}
140 + onClick={() => void handleDelete(session.id)}
141 + >
142 + &times;
143 + </button>
144 + </li>
145 + ))}
146 + </ul>
147 + </div>
148 + )}
112 149 </div>
113 150 )
114 151 }
@@ -118,8 +155,11 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
118 155 const [answerText, setAnswerText] = useState('')
119 156 const [error, setError] = useState<string | null>(null)
120 157 const [submitting, setSubmitting] = useState(false)
121 - const [ttsEnabled, setTtsEnabled] = useState(false)
158 + const [recording, setRecording] = useState(false)
159 + const [speaking, setSpeaking] = useState(false)
160 + const [ttsEnabled, setTtsEnabled] = useState(true)
122 161 const lastSpokenQuestion = useRef<string | null>(null)
162 + const levelRef = useRef(0)
123 163
124 164 function refresh() {
125 165 return api.getSession(sessionId).then(setSession)
@@ -130,14 +170,48 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
130 170 }, [sessionId])
131 171
132 172 useEffect(() => {
133 - if (!ttsEnabled || !session) return
173 + if (!ttsEnabled) {
174 + stopSpeaking()
175 + setSpeaking(false)
176 + return
177 + }
178 + if (!session) return
134 179 const question = latestQuestion(session)
135 180 if (question && question !== lastSpokenQuestion.current) {
136 181 lastSpokenQuestion.current = question
137 - speak(question)
182 + speak(question, {
183 + onStart: () => setSpeaking(true),
184 + onEnd: () => setSpeaking(false),
185 + })
138 186 }
139 187 }, [ttsEnabled, session])
140 188
189 + useEffect(() => {
190 + return () => stopSpeaking()
191 + }, [])
192 +
193 + useEffect(() => {
194 + function handleKeyDown(event: KeyboardEvent) {
195 + if (event.key !== 'Escape') return
196 + stopSpeaking()
197 + setSpeaking(false)
198 + }
199 + window.addEventListener('keydown', handleKeyDown)
200 + return () => window.removeEventListener('keydown', handleKeyDown)
201 + }, [])
202 +
203 + const handleRecordingChange = useCallback((isRecording: boolean) => {
204 + setRecording(isRecording)
205 + if (isRecording) {
206 + stopSpeaking()
207 + setSpeaking(false)
208 + }
209 + }, [])
210 +
211 + const handleLevel = useCallback((level: number) => {
212 + levelRef.current = level
213 + }, [])
214 +
141 215 async function submit(text: string) {
142 216 if (!text.trim()) return
143 217 setError(null)
@@ -174,79 +248,95 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
174 248 if (!session) {
175 249 return (
176 250 <div className="screen interview-screen">
177 - <button type="button" className="back-link" onClick={onBack}>
178 - ← Back to your projects
179 - </button>
180 - {error ? <p className="error">{error}</p> : <p>Loading…</p>}
251 + <header className="topbar">
252 + <button type="button" className="back-link" onClick={onBack}>
253 + &larr; Sessions
254 + </button>
255 + </header>
256 + {error ? <p className="error">{error}</p> : <p className="muted">Loading&hellip;</p>}
181 257 </div>
182 258 )
183 259 }
184 260
261 + const interviewing = session.status === 'interviewing'
262 + const voiceState: VoiceState = recording
263 + ? 'listening'
264 + : submitting
265 + ? 'thinking'
266 + : speaking
267 + ? 'speaking'
268 + : 'idle'
269 + const waveformMode: WaveformMode =
270 + voiceState === 'listening' ? 'live' : voiceState === 'speaking' ? 'simulated' : 'idle'
271 +
185 272 return (
186 273 <div className="screen interview-screen">
187 - <button type="button" className="back-link" onClick={onBack}>
188 - ← Back to your projects
189 - </button>
190 - <div className="interview-header">
191 - <h1>{session.name}</h1>
192 - <div className="interview-header-actions">
274 + <header className="topbar">
275 + <button type="button" className="back-link" onClick={onBack}>
276 + &larr; Sessions
277 + </button>
278 + <span className="topbar-title">{session.name}</span>
279 + <div className="topbar-actions">
280 + <TranscriptExport sessionId={session.id} projectName={session.name} segments={session.segments} />
193 281 <label className="tts-toggle">
194 282 <input
195 283 type="checkbox"
196 284 checked={ttsEnabled}
197 285 onChange={(e) => setTtsEnabled(e.target.checked)}
198 286 />
199 - Read questions aloud
287 + <span className="tts-track" aria-hidden="true" />
288 + voice
200 289 </label>
201 - <TranscriptExport sessionId={session.id} projectName={session.name} segments={session.segments} />
202 290 </div>
203 - </div>
291 + </header>
204 292
205 - <div className="interview-layout">
206 - <div className="interview-main">
207 - <QuestionCard question={latestQuestion(session)} />
208 - <Transcript segments={session.segments} />
209 -
210 - {session.status === 'interviewing' ? (
211 - <>
212 - <PushToTalkButton disabled={submitting} onRecorded={(blob) => void submitAudio(blob)} />
213 - <form className="answer-form" onSubmit={handleSubmit}>
214 - <input
215 - value={answerText}
216 - onChange={(e) => setAnswerText(e.target.value)}
217 - placeholder="Type your answer…"
218 - disabled={submitting}
219 - autoFocus
220 - />
221 - <button type="submit" disabled={submitting || !answerText.trim()}>
222 - Send
223 - </button>
224 - <button
225 - type="button"
226 - className="done-button"
227 - disabled={submitting}
228 - onClick={() => submit('done')}
229 - >
230 - Done
231 - </button>
232 - </form>
233 - </>
234 - ) : (
235 - <>
236 - <p className="interview-done">You're all set! Here's what's next.</p>
293 + <CoveragePanel coverage={session.coverage} />
294 +
295 + <main className="stage">
296 + <VoiceOrb state={voiceState} levelRef={levelRef} />
297 +
298 + {interviewing ? (
299 + <>
300 + <QuestionCard question={latestQuestion(session)} />
301 + <Waveform mode={waveformMode} levelRef={levelRef} />
302 + <p className="status-caption">{CAPTIONS[voiceState]}</p>
303 +
304 + <PushToTalkButton
305 + disabled={submitting}
306 + onRecorded={(blob) => void submitAudio(blob)}
307 + onRecordingChange={handleRecordingChange}
308 + onLevel={handleLevel}
309 + />
310 +
311 + <form className="answer-form" onSubmit={handleSubmit}>
312 + <input
313 + value={answerText}
314 + onChange={(e) => setAnswerText(e.target.value)}
315 + placeholder="or type your answer…"
316 + disabled={submitting}
317 + />
318 + <button type="submit" className="btn-primary" disabled={submitting || !answerText.trim()}>
319 + Send
320 + </button>
321 + </form>
322 + <button type="button" className="done-button" disabled={submitting} onClick={() => submit('done')}>
323 + I'm done, wrap it up
324 + </button>
325 + </>
326 + ) : (
327 + <>
328 + <p className="interview-done">Interview complete.</p>
329 + <div className="finish-panels">
237 330 <GeneratePanel sessionId={session.id} coverage={session.coverage} />
238 331 <BlockerImport sessionId={session.id} onImported={refresh} />
239 - </>
240 - )}
332 + </div>
333 + </>
334 + )}
241 335
242 - {error && <p className="error">{error}</p>}
243 - </div>
336 + {error && <p className="error">{error}</p>}
337 + </main>
244 338
245 - <aside className="interview-sidebar">
246 - <h2>Progress</h2>
247 - <CoveragePanel coverage={session.coverage} />
248 - </aside>
249 - </div>
339 + <Transcript segments={session.segments} />
250 340 </div>
251 341 )
252 342 }
modified client/src/api.ts +8 −0
@@ -41,6 +41,14 @@export function getSession(id: string): Promise<Session> {
41 41 return requestJson<Session>(`${BASE}/sessions/${id}`)
42 42 }
43 43
44 +export async function deleteSession(id: string): Promise<void> {
45 + const res = await fetch(`${BASE}/sessions/${id}`, { method: 'DELETE' })
46 + if (!res.ok) {
47 + const body = (await res.json().catch(() => null)) as { error?: string } | null
48 + throw new Error(body?.error ?? `request failed with status ${res.status}`)
49 + }
50 +}
51 +
44 52 export function submitAnswer(id: string, req: AnswerRequest): Promise<AnswerResponse> {
45 53 return requestJson<AnswerResponse>(`${BASE}/sessions/${id}/answer`, {
46 54 method: 'POST',
modified client/src/audio.ts +29 −2
@@ -1,26 +1,53 @@
1 1 export interface PushToTalkRecorder {
2 - start(): Promise<void>
2 + start(onLevel?: (level: number) => void): Promise<void>
3 3 stop(): Promise<Blob>
4 4 }
5 5
6 6 export function createPushToTalkRecorder(): PushToTalkRecorder {
7 7 let mediaRecorder: MediaRecorder | null = null
8 8 let stream: MediaStream | null = null
9 9 let chunks: BlobPart[] = []
10 + let audioContext: AudioContext | null = null
11 + let rafId = 0
10 12
11 13 return {
12 - async start() {
14 + async start(onLevel) {
13 15 stream = await navigator.mediaDevices.getUserMedia({ audio: true })
14 16 chunks = []
15 17 mediaRecorder = new MediaRecorder(stream)
16 18 mediaRecorder.ondataavailable = (event) => {
17 19 if (event.data.size > 0) chunks.push(event.data)
18 20 }
19 21 mediaRecorder.start()
22 +
23 + if (onLevel) {
24 + audioContext = new AudioContext()
25 + const source = audioContext.createMediaStreamSource(stream)
26 + const analyser = audioContext.createAnalyser()
27 + analyser.fftSize = 512
28 + source.connect(analyser)
29 + const data = new Uint8Array(analyser.fftSize)
30 + const tick = () => {
31 + analyser.getByteTimeDomainData(data)
32 + let sum = 0
33 + for (let i = 0; i < data.length; i++) {
34 + const v = ((data[i] ?? 128) - 128) / 128
35 + sum += v * v
36 + }
37 + onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4))
38 + rafId = requestAnimationFrame(tick)
39 + }
40 + rafId = requestAnimationFrame(tick)
41 + }
20 42 },
21 43
22 44 stop() {
23 45 return new Promise<Blob>((resolve, reject) => {
46 + cancelAnimationFrame(rafId)
47 + if (audioContext) {
48 + void audioContext.close()
49 + audioContext = null
50 + }
24 51 if (!mediaRecorder) {
25 52 reject(new Error('recording was not started'))
26 53 return
modified client/src/components/CoveragePanel.tsx +1 −1
@@ -18,7 +18,7 @@export function CoveragePanel({ coverage }: CoveragePanelProps) {
18 18 <li
19 19 key={category}
20 20 className={`coverage-item coverage-${coverage[category]}`}
21 - title={CATEGORY_LABELS[category].description}
21 + title={`${CATEGORY_LABELS[category].description} (${coverage[category]})`}
22 22 >
23 23 <span className="coverage-dot" aria-hidden="true" />
24 24 {CATEGORY_LABELS[category].label}
modified client/src/components/PushToTalkButton.tsx +49 −6
@@ -4,15 +4,26 @@import { createPushToTalkRecorder } from '../audio'
4 4 interface PushToTalkButtonProps {
5 5 disabled?: boolean
6 6 onRecorded: (blob: Blob) => void
7 + onRecordingChange?: (recording: boolean) => void
8 + onLevel?: (level: number) => void
7 9 }
8 10
9 11 function isTypingTarget(target: EventTarget | null): boolean {
10 12 if (!(target instanceof HTMLElement)) return false
11 13 return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
12 14 }
13 15
14 -export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps) {
16 +const MAX_RECORDING_SECONDS = 120
17 +
18 +function formatElapsed(seconds: number): string {
19 + const m = Math.floor(seconds / 60)
20 + const s = seconds % 60
21 + return `${m}:${String(s).padStart(2, '0')}`
22 +}
23 +
24 +export function PushToTalkButton({ disabled, onRecorded, onRecordingChange, onLevel }: PushToTalkButtonProps) {
15 25 const [recording, setRecording] = useState(false)
26 + const [elapsed, setElapsed] = useState(0)
16 27 const [error, setError] = useState<string | null>(null)
17 28 const recorderRef = useRef(createPushToTalkRecorder())
18 29 const activeRef = useRef(false)
@@ -22,25 +33,41 @@export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps
22 33 activeRef.current = true
23 34 setError(null)
24 35 try {
25 - await recorderRef.current.start()
36 + await recorderRef.current.start(onLevel)
26 37 setRecording(true)
38 + onRecordingChange?.(true)
27 39 } catch (err) {
28 40 activeRef.current = false
29 41 setError(err instanceof Error ? err.message : 'could not access the microphone')
30 42 }
31 - }, [disabled])
43 + }, [disabled, onLevel, onRecordingChange])
32 44
33 45 const stopRecording = useCallback(async () => {
34 46 if (!activeRef.current) return
35 47 activeRef.current = false
36 48 setRecording(false)
49 + onRecordingChange?.(false)
37 50 try {
38 51 const blob = await recorderRef.current.stop()
39 52 onRecorded(blob)
40 53 } catch (err) {
41 54 setError(err instanceof Error ? err.message : 'recording failed')
42 55 }
43 - }, [onRecorded])
56 + }, [onRecorded, onRecordingChange])
57 +
58 + useEffect(() => {
59 + if (!recording) {
60 + setElapsed(0)
61 + return
62 + }
63 + const startedAt = Date.now()
64 + const interval = window.setInterval(() => {
65 + const seconds = Math.floor((Date.now() - startedAt) / 1000)
66 + setElapsed(seconds)
67 + if (seconds >= MAX_RECORDING_SECONDS) void stopRecording()
68 + }, 500)
69 + return () => window.clearInterval(interval)
70 + }, [recording, stopRecording])
44 71
45 72 useEffect(() => {
46 73 function handleKeyDown(event: KeyboardEvent) {
@@ -67,16 +94,32 @@export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps
67 94 <div className="push-to-talk">
68 95 <button
69 96 type="button"
70 - className={`record-button ${recording ? 'recording' : ''}`}
97 + className={`mic-button ${recording ? 'recording' : ''}`}
71 98 disabled={disabled}
99 + aria-label={recording ? 'Release to send' : 'Hold to talk'}
72 100 onMouseDown={() => void startRecording()}
73 101 onMouseUp={() => void stopRecording()}
74 102 onMouseLeave={() => {
75 103 if (recording) void stopRecording()
76 104 }}
105 + onTouchStart={(e) => {
106 + e.preventDefault()
107 + void startRecording()
108 + }}
109 + onTouchEnd={(e) => {
110 + e.preventDefault()
111 + void stopRecording()
112 + }}
77 113 >
78 - {recording ? 'Recording… release to send' : 'Hold to talk (or press space)'}
114 + <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
115 + <rect x="9" y="3" width="6" height="11" rx="3" />
116 + <path d="M5 11a7 7 0 0 0 14 0" />
117 + <path d="M12 18v3" />
118 + </svg>
79 119 </button>
120 + <p className="mic-hint">
121 + {recording ? `${formatElapsed(elapsed)} · release to send` : 'hold to talk · or press space'}
122 + </p>
80 123 {error && <p className="error">{error}</p>}
81 124 </div>
82 125 )
modified client/src/components/QuestionCard.tsx +2 −5
@@ -3,12 +3,9 @@interface QuestionCardProps {
3 3 }
4 4
5 5 export function QuestionCard({ question }: QuestionCardProps) {
6 - if (!question) return null
7 -
8 6 return (
9 - <div className="question-card">
10 - <span className="question-label">Question</span>
11 - <p className="question-text">{question}</p>
7 + <div className="question" key={question ?? 'intro'}>
8 + <p className="question-text">{question ?? 'Tell me about the thing you want to build.'}</p>
12 9 </div>
13 10 )
14 11 }
modified client/src/components/Transcript.tsx +20 −11
@@ -1,22 +1,31 @@
1 +import { useEffect, useRef } from 'react'
1 2 import type { Segment } from 'shared/types'
2 3
3 4 interface TranscriptProps {
4 5 segments: Segment[]
5 6 }
6 7
7 8 export function Transcript({ segments }: TranscriptProps) {
8 - if (segments.length === 0) {
9 - return <p className="transcript-empty">No answers yet. Say or type what you want to build.</p>
10 - }
9 + const listRef = useRef<HTMLDivElement>(null)
10 +
11 + useEffect(() => {
12 + const list = listRef.current
13 + if (list) list.scrollTop = list.scrollHeight
14 + }, [segments.length])
15 +
16 + if (segments.length === 0) return null
11 17
12 18 return (
13 - <div className="transcript">
14 - {segments.map((segment) => (
15 - <div key={segment.id} className={`segment segment-${segment.speaker}`}>
16 - <span className="segment-speaker">{segment.speaker === 'user' ? 'You' : 'Interviewer'}</span>
17 - <p className="segment-text">{segment.text}</p>
18 - </div>
19 - ))}
20 - </div>
19 + <section className="transcript-section">
20 + <h2 className="transcript-title">Transcript</h2>
21 + <div ref={listRef} className="transcript">
22 + {segments.map((segment) => (
23 + <div key={segment.id} className={`segment segment-${segment.speaker}`}>
24 + <span className="segment-speaker">{segment.speaker === 'user' ? 'You' : 'Interviewer'}</span>
25 + <p className="segment-text">{segment.text}</p>
26 + </div>
27 + ))}
28 + </div>
29 + </section>
21 30 )
22 31 }
added client/src/components/VoiceOrb.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import { useEffect, useRef } from 'react'
2 +
3 +export type VoiceState = 'idle' | 'listening' | 'thinking' | 'speaking'
4 +
5 +interface VoiceOrbProps {
6 + state: VoiceState
7 + levelRef?: React.RefObject<number>
8 +}
9 +
10 +export function VoiceOrb({ state, levelRef }: VoiceOrbProps) {
11 + const ringRef = useRef<HTMLDivElement>(null)
12 +
13 + useEffect(() => {
14 + if (state !== 'listening' || !levelRef) return
15 + let rafId = 0
16 + const tick = () => {
17 + const ring = ringRef.current
18 + if (ring) {
19 + const level = levelRef.current ?? 0
20 + ring.style.transform = `scale(${1 + level * 0.3})`
21 + ring.style.opacity = String(0.25 + level * 0.6)
22 + }
23 + rafId = requestAnimationFrame(tick)
24 + }
25 + rafId = requestAnimationFrame(tick)
26 + return () => {
27 + cancelAnimationFrame(rafId)
28 + const ring = ringRef.current
29 + if (ring) {
30 + ring.style.transform = ''
31 + ring.style.opacity = ''
32 + }
33 + }
34 + }, [state, levelRef])
35 +
36 + return (
37 + <div className={`orb-wrap orb-${state}`} aria-hidden="true">
38 + <div ref={ringRef} className="orb-level-ring" />
39 + <div className="orb-ripple orb-ripple-1" />
40 + <div className="orb-ripple orb-ripple-2" />
41 + <div className="orb">
42 + <div className="orb-face">
43 + <div className="orb-eyes">
44 + <span className="orb-eye" />
45 + <span className="orb-eye" />
46 + </div>
47 + <div className="orb-mouth">
48 + <span className="orb-mouth-bar" />
49 + <span className="orb-mouth-bar" />
50 + <span className="orb-mouth-bar" />
51 + <span className="orb-mouth-bar" />
52 + </div>
53 + </div>
54 + </div>
55 + </div>
56 + )
57 +}
added client/src/components/Waveform.tsx +64 −0
@@ -0,0 +1,64 @@
1 +import { useEffect, useRef } from 'react'
2 +
3 +export type WaveformMode = 'idle' | 'live' | 'simulated'
4 +
5 +interface WaveformProps {
6 + mode: WaveformMode
7 + levelRef?: React.RefObject<number>
8 +}
9 +
10 +const BAR_COUNT = 36
11 +
12 +export function Waveform({ mode, levelRef }: WaveformProps) {
13 + const barRefs = useRef<(HTMLSpanElement | null)[]>([])
14 + const historyRef = useRef<number[]>(Array<number>(BAR_COUNT).fill(0))
15 +
16 + useEffect(() => {
17 + if (mode !== 'live' || !levelRef) {
18 + historyRef.current = Array<number>(BAR_COUNT).fill(0)
19 + for (const bar of barRefs.current) {
20 + if (bar) bar.style.transform = ''
21 + }
22 + return
23 + }
24 + let rafId = 0
25 + let lastPush = 0
26 + const tick = (time: number) => {
27 + if (time - lastPush > 40) {
28 + lastPush = time
29 + historyRef.current.push(levelRef.current ?? 0)
30 + historyRef.current.shift()
31 + }
32 + const history = historyRef.current
33 + for (let i = 0; i < BAR_COUNT; i++) {
34 + const bar = barRefs.current[i]
35 + if (bar) bar.style.transform = `scaleY(${0.08 + (history[i] ?? 0) * 0.92})`
36 + }
37 + rafId = requestAnimationFrame(tick)
38 + }
39 + rafId = requestAnimationFrame(tick)
40 + return () => cancelAnimationFrame(rafId)
41 + }, [mode, levelRef])
42 +
43 + return (
44 + <div className={`waveform waveform-${mode}`} aria-hidden="true">
45 + {Array.from({ length: BAR_COUNT }, (_, i) => (
46 + <span
47 + key={i}
48 + ref={(el) => {
49 + barRefs.current[i] = el
50 + }}
51 + className="waveform-bar"
52 + style={
53 + mode === 'simulated'
54 + ? {
55 + animationDelay: `${(i % 7) * 0.09}s`,
56 + animationDuration: `${0.7 + (i % 5) * 0.13}s`,
57 + }
58 + : undefined
59 + }
60 + />
61 + ))}
62 + </div>
63 + )
64 +}
modified client/src/index.css +48 −30
@@ -1,32 +1,25 @@
1 1 :root {
2 - --text: #3c3a41;
3 - --text-h: #08060d;
4 - --bg: #fff;
5 - --bg-alt: #f6f5f8;
6 - --border: #e5e4e7;
7 - --accent: #7c3aed;
8 - --accent-bg: rgba(124, 58, 237, 0.1);
9 - --danger: #b3261e;
10 - --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
11 -
12 - font: 16px/145% var(--sans);
13 - color-scheme: light dark;
14 - color: var(--text);
15 - background: var(--bg);
16 - -webkit-font-smoothing: antialiased;
17 -}
2 + --paper: #f6f1e7;
3 + --paper-deep: #eee7d7;
4 + --card: #fcfaf4;
5 + --ink: #2c2620;
6 + --ink-soft: #7a6f61;
7 + --line: #e3d9c8;
8 + --clay: #c46a4a;
9 + --clay-deep: #a95538;
10 + --clay-soft: #f0ddd2;
11 + --moss: #7a8b6f;
12 + --amber: #d9a441;
13 + --danger: #b2503c;
18 14
19 -@media (prefers-color-scheme: dark) {
20 - :root {
21 - --text: #d1d0d6;
22 - --text-h: #f3f4f6;
23 - --bg: #16171d;
24 - --bg-alt: #1c1d24;
25 - --border: #2e303a;
26 - --accent: #c084fc;
27 - --accent-bg: rgba(192, 132, 252, 0.15);
28 - --danger: #f2a29c;
29 - }
15 + --serif: 'Fraunces', Georgia, serif;
16 + --sans: 'Instrument Sans', 'Segoe UI', sans-serif;
17 +
18 + font: 16px/1.55 var(--sans);
19 + color-scheme: light;
20 + color: var(--ink);
21 + background: var(--paper);
22 + -webkit-font-smoothing: antialiased;
30 23 }
31 24
32 25 * {
@@ -35,16 +28,41 @@
35 28
36 29 body {
37 30 margin: 0;
31 + min-height: 100vh;
32 + background:
33 + radial-gradient(1100px 500px at 50% -10%, rgba(196, 106, 74, 0.07), transparent 65%),
34 + var(--paper);
35 +}
36 +
37 +body::after {
38 + content: '';
39 + position: fixed;
40 + inset: 0;
41 + pointer-events: none;
42 + opacity: 0.05;
43 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
38 44 }
39 45
40 46 h1,
41 47 h2 {
42 - font-family: var(--sans);
43 - font-weight: 600;
44 - color: var(--text-h);
48 + font-family: var(--serif);
49 + font-weight: 500;
50 + color: var(--ink);
45 51 margin: 0 0 8px;
46 52 }
47 53
48 54 p {
49 55 margin: 0;
50 56 }
57 +
58 +button {
59 + font-family: var(--sans);
60 +}
61 +
62 +input {
63 + font-family: var(--sans);
64 +}
65 +
66 +::selection {
67 + background: var(--clay-soft);
68 +}
modified client/src/tts.ts +33 −2
@@ -1,7 +1,38 @@
1 -export function speak(text: string): void {
1 +export interface SpeakCallbacks {
2 + onStart?: () => void
3 + onEnd?: () => void
4 +}
5 +
6 +function pickVoice(): SpeechSynthesisVoice | null {
7 + const voices = window.speechSynthesis.getVoices()
8 + let best: SpeechSynthesisVoice | null = null
9 + let bestScore = 0
10 + for (const voice of voices) {
11 + let score = 1
12 + if (voice.lang.toLowerCase().startsWith('en')) score += 2
13 + if (/natural/i.test(voice.name)) score += 4
14 + if (/online/i.test(voice.name)) score += 3
15 + if (/google/i.test(voice.name)) score += 2
16 + if (score > bestScore) {
17 + bestScore = score
18 + best = voice
19 + }
20 + }
21 + return best
22 +}
23 +
24 +export function speak(text: string, callbacks: SpeakCallbacks = {}): void {
2 25 if (!('speechSynthesis' in window)) return
3 26 window.speechSynthesis.cancel()
4 - window.speechSynthesis.speak(new SpeechSynthesisUtterance(text))
27 + const utterance = new SpeechSynthesisUtterance(text)
28 + const voice = pickVoice()
29 + if (voice) utterance.voice = voice
30 + utterance.rate = 1
31 + utterance.pitch = 1
32 + utterance.onstart = () => callbacks.onStart?.()
33 + utterance.onend = () => callbacks.onEnd?.()
34 + utterance.onerror = () => callbacks.onEnd?.()
35 + window.speechSynthesis.speak(utterance)
5 36 }
6 37
7 38 export function stopSpeaking(): void {
modified package-lock.json +35 −9
@@ -8,14 +8,14 @@
8 8 "name": "voicetask",
9 9 "version": "0.1.0",
10 10 "dependencies": {
11 - "@anthropic-ai/sdk": "^0.71.0",
11 + "@anthropic-ai/sdk": "^0.113.0",
12 12 "@fastify/cors": "^10.0.2",
13 13 "@fastify/multipart": "^9.2.1",
14 14 "@fastify/static": "^8.2.0",
15 15 "fastify": "^5.6.1",
16 16 "react": "^19.2.7",
17 17 "react-dom": "^19.2.7",
18 - "zod": "^3.25.76"
18 + "zod": "^4.4.3"
19 19 },
20 20 "devDependencies": {
21 21 "@types/node": "^24.13.2",
@@ -27,15 +27,19 @@
27 27 "typescript": "~5.9.3",
28 28 "vite": "^8.1.1",
29 29 "vitest": "^3.2.4"
30 + },
31 + "engines": {
32 + "node": ">=20.12"
30 33 }
31 34 },
32 35 "node_modules/@anthropic-ai/sdk": {
33 - "version": "0.71.2",
34 - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
35 - "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
36 + "version": "0.113.0",
37 + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.113.0.tgz",
38 + "integrity": "sha512-8YKIP333ypmy8QjtSP691fIqdes3HL/rSXAT4FDZX/HRnkQaNG+iOVBfEQw4vjPNpYHeoy7FSYc2o/jb8b3AnA==",
36 39 "license": "MIT",
37 40 "dependencies": {
38 - "json-schema-to-ts": "^3.1.1"
41 + "json-schema-to-ts": "^3.1.1",
42 + "standardwebhooks": "^1.0.0"
39 43 },
40 44 "bin": {
41 45 "anthropic-ai-sdk": "bin/cli"
@@ -1447,6 +1451,12 @@
1447 1451 "win32"
1448 1452 ]
1449 1453 },
1454 + "node_modules/@stablelib/base64": {
1455 + "version": "1.0.1",
1456 + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
1457 + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
1458 + "license": "MIT"
1459 + },
1450 1460 "node_modules/@tybys/wasm-util": {
1451 1461 "version": "0.10.3",
1452 1462 "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -2134,6 +2144,12 @@
2134 2144 "fast-decode-uri-component": "^1.0.1"
2135 2145 }
2136 2146 },
2147 + "node_modules/fast-sha256": {
2148 + "version": "1.3.0",
2149 + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
2150 + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
2151 + "license": "Unlicense"
2152 + },
2137 2153 "node_modules/fast-uri": {
2138 2154 "version": "3.1.4",
2139 2155 "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
@@ -3340,6 +3356,16 @@
3340 3356 "dev": true,
3341 3357 "license": "MIT"
3342 3358 },
3359 + "node_modules/standardwebhooks": {
3360 + "version": "1.0.0",
3361 + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
3362 + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
3363 + "license": "MIT",
3364 + "dependencies": {
3365 + "@stablelib/base64": "^1.0.0",
3366 + "fast-sha256": "^1.3.0"
3367 + }
3368 + },
3343 3369 "node_modules/statuses": {
3344 3370 "version": "2.0.2",
3345 3371 "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4014,9 +4040,9 @@
4014 4040 }
4015 4041 },
4016 4042 "node_modules/zod": {
4017 - "version": "3.25.76",
4018 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
4019 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
4043 + "version": "4.4.3",
4044 + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
4045 + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
4020 4046 "license": "MIT",
4021 4047 "funding": {
4022 4048 "url": "https://github.com/sponsors/colinhacks"
modified package.json +7 −3
@@ -3,6 +3,9 @@
3 3 "private": true,
4 4 "version": "0.1.0",
5 5 "type": "module",
6 + "engines": {
7 + "node": ">=20.12"
8 + },
6 9 "scripts": {
7 10 "dev": "concurrently -k -n server,client -c blue,green \"npm:dev:server\" \"npm:dev:client\"",
8 11 "dev:server": "tsx watch server/index.ts",
@@ -11,17 +14,18 @@
11 14 "build:client": "vite build --config client/vite.config.ts",
12 15 "build:server": "tsc -p server/tsconfig.json",
13 16 "typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p client/tsconfig.app.json --noEmit && tsc -p shared/tsconfig.json --noEmit",
14 - "test": "vitest run"
17 + "test": "vitest run",
18 + "check": "npm run typecheck && npm test && npm run build"
15 19 },
16 20 "dependencies": {
17 - "@anthropic-ai/sdk": "^0.71.0",
21 + "@anthropic-ai/sdk": "^0.113.0",
18 22 "@fastify/cors": "^10.0.2",
19 23 "@fastify/multipart": "^9.2.1",
20 24 "@fastify/static": "^8.2.0",
21 25 "fastify": "^5.6.1",
22 26 "react": "^19.2.7",
23 27 "react-dom": "^19.2.7",
24 - "zod": "^3.25.76"
28 + "zod": "^4.4.3"
25 29 },
26 30 "devDependencies": {
27 31 "@types/node": "^24.13.2",
modified server/index.ts +3 −0
@@ -1,5 +1,8 @@
1 +import { existsSync } from 'node:fs'
1 2 import { buildApp } from './app'
2 3
4 +if (existsSync('.env')) process.loadEnvFile('.env')
5 +
3 6 const PORT = Number(process.env.PORT ?? 3001)
4 7
5 8 async function main() {
modified server/providers/llmAnthropic.ts +24 −15
@@ -1,12 +1,11 @@
1 1 import Anthropic from '@anthropic-ai/sdk'
2 -import { betaZodOutputFormat } from '@anthropic-ai/sdk/helpers/beta/zod'
2 +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'
3 3 import { CATEGORY_IDS, InterviewTurnSchema, type InterviewTurn } from '../../shared/types'
4 4 import { weakestCategory } from '../engine/coverage'
5 5 import type { GenerateFileRequest, InterviewContext, InterviewLlm } from './types'
6 6
7 7 const DEFAULT_MODEL = 'claude-opus-4-8'
8 -const INTERVIEW_MAX_TOKENS = 4096
9 -const INTERVIEW_THINKING_BUDGET_TOKENS = 2048
8 +const INTERVIEW_MAX_TOKENS = 16000
10 9 const GENERATE_MAX_TOKENS = 32000
11 10
12 11 export function buildInterviewSystemPrompt(context: InterviewContext): string {
@@ -45,24 +44,34 @@export function createLlmAnthropic(): InterviewLlm {
45 44
46 45 return {
47 46 async nextTurn(context: InterviewContext): Promise<InterviewTurn> {
48 - const message = await client.beta.messages.parse({
49 - model,
50 - max_tokens: INTERVIEW_MAX_TOKENS,
51 - thinking: { type: 'enabled', budget_tokens: INTERVIEW_THINKING_BUDGET_TOKENS },
52 - system: buildInterviewSystemPrompt(context),
53 - messages: [{ role: 'user', content: buildInterviewUserMessage(context) }],
54 - output_format: betaZodOutputFormat(InterviewTurnSchema),
55 - })
56 - if (!message.parsed_output) {
57 - throw new Error('Anthropic response did not include a parsed InterviewTurn')
47 + const runTurn = async (): Promise<InterviewTurn> => {
48 + const message = await client.messages.parse({
49 + model,
50 + max_tokens: INTERVIEW_MAX_TOKENS,
51 + thinking: { type: 'adaptive' },
52 + system: buildInterviewSystemPrompt(context),
53 + messages: [{ role: 'user', content: buildInterviewUserMessage(context) }],
54 + output_config: { format: zodOutputFormat(InterviewTurnSchema) },
55 + })
56 + if (!message.parsed_output) {
57 + throw new Error('Anthropic response did not include a parsed InterviewTurn')
58 + }
59 + return message.parsed_output
60 + }
61 + try {
62 + return await runTurn()
63 + } catch (err) {
64 + // API errors were already retried by the SDK; retry once only on schema/parse failures
65 + if (err instanceof Anthropic.APIError) throw err
66 + return runTurn()
58 67 }
59 - return message.parsed_output
60 68 },
61 69
62 70 async generateFile(request: GenerateFileRequest): Promise<string> {
63 - const stream = client.beta.messages.stream({
71 + const stream = client.messages.stream({
64 72 model,
65 73 max_tokens: GENERATE_MAX_TOKENS,
74 + thinking: { type: 'adaptive' },
66 75 messages: [{ role: 'user', content: request.prompt }],
67 76 })
68 77 const final = await stream.finalMessage()
modified server/routes/sessions.test.ts +18 −1
@@ -40,7 +40,9 @@describe('session routes', () => {
40 40 .then((r) => r.json<Session>())
41 41
42 42 const list = await app.inject({ method: 'GET', url: '/api/sessions' })
43 - expect(list.json()).toEqual([{ id: created.id, name: 'p', status: 'interviewing' }])
43 + expect(list.json()).toEqual([
44 + { id: created.id, name: 'p', status: 'interviewing', createdAt: created.createdAt },
45 + ])
44 46
45 47 const fetched = await app.inject({ method: 'GET', url: `/api/sessions/${created.id}` })
46 48 expect(fetched.json<Session>()).toEqual(created)
@@ -49,6 +51,21 @@describe('session routes', () => {
49 51 expect(missing.statusCode).toBe(404)
50 52 })
51 53
54 + it('deletes a session', async () => {
55 + const created = await app
56 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
57 + .then((r) => r.json<Session>())
58 +
59 + const deleted = await app.inject({ method: 'DELETE', url: `/api/sessions/${created.id}` })
60 + expect(deleted.statusCode).toBe(204)
61 +
62 + const fetched = await app.inject({ method: 'GET', url: `/api/sessions/${created.id}` })
63 + expect(fetched.statusCode).toBe(404)
64 +
65 + const again = await app.inject({ method: 'DELETE', url: `/api/sessions/${created.id}` })
66 + expect(again.statusCode).toBe(404)
67 + })
68 +
52 69 it('answering produces a user segment, an interviewer segment, and a coverage update', async () => {
53 70 const created = await app
54 71 .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
modified server/routes/sessions.ts +6 −0
@@ -31,6 +31,12 @@export function registerSessionRoutes(app: FastifyInstance, deps: SessionRouteDe
31 31 return session
32 32 })
33 33
34 + app.delete<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => {
35 + const deleted = await store.deleteSession(request.params.id)
36 + if (!deleted) return reply.code(404).send({ error: 'session not found' })
37 + return reply.code(204).send()
38 + })
39 +
34 40 app.post<{ Params: { id: string } }>('/api/sessions/:id/answer', async (request, reply) => {
35 41 const parsed = AnswerRequestSchema.safeParse(request.body)
36 42 if (!parsed.success) {
modified server/store/sessionStore.test.ts +13 −3
@@ -60,7 +60,7 @@describe('SessionStore', () => {
60 60 expect(updated.status).toBe('done')
61 61 })
62 62
63 - it('lists sessions with id, name, status only', async () => {
63 + it('lists sessions as summaries, newest first', async () => {
64 64 const a = await store.createSession('alpha', '/tmp/a')
65 65 const b = await store.createSession('beta', '/tmp/b')
66 66 await store.updateTurn(b.id, { status: 'done' })
@@ -69,10 +69,20 @@describe('SessionStore', () => {
69 69 expect(list).toHaveLength(2)
70 70 expect(list).toEqual(
71 71 expect.arrayContaining([
72 - { id: a.id, name: 'alpha', status: 'interviewing' },
73 - { id: b.id, name: 'beta', status: 'done' },
72 + { id: a.id, name: 'alpha', status: 'interviewing', createdAt: a.createdAt },
73 + { id: b.id, name: 'beta', status: 'done', createdAt: b.createdAt },
74 74 ]),
75 75 )
76 + const sorted = [...list].sort((x, y) => y.createdAt.localeCompare(x.createdAt))
77 + expect(list).toEqual(sorted)
78 + })
79 +
80 + it('deletes a session and reports missing ones', async () => {
81 + const session = await store.createSession('gone soon', '/tmp/g')
82 + expect(await store.deleteSession(session.id)).toBe(true)
83 + expect(await store.getSession(session.id)).toBeNull()
84 + expect(await store.listSessions()).toEqual([])
85 + expect(await store.deleteSession(session.id)).toBe(false)
76 86 })
77 87
78 88 it('lists no sessions when the base directory does not exist yet', async () => {
modified server/store/sessionStore.ts +17 −2
@@ -1,5 +1,5 @@
1 1 import { randomUUID } from 'node:crypto'
2 -import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
2 +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
3 3 import path from 'node:path'
4 4 import {
5 5 SessionSchema,
@@ -69,11 +69,26 @@export class SessionStore {
69 69 const summaries: SessionSummary[] = []
70 70 for (const id of entries) {
71 71 const session = await this.getSession(id)
72 - if (session) summaries.push({ id: session.id, name: session.name, status: session.status })
72 + if (session) {
73 + summaries.push({
74 + id: session.id,
75 + name: session.name,
76 + status: session.status,
77 + createdAt: session.createdAt,
78 + })
79 + }
73 80 }
81 + summaries.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
74 82 return summaries
75 83 }
76 84
85 + async deleteSession(id: string): Promise<boolean> {
86 + const session = await this.getSession(id)
87 + if (!session) return false
88 + await rm(this.sessionDir(id), { recursive: true, force: true })
89 + return true
90 + }
91 +
77 92 async appendSegment(
78 93 id: string,
79 94 speaker: Speaker,
modified shared/types.ts +6 −1
@@ -55,7 +55,12 @@export const SessionSchema = z.object({
55 55 })
56 56 export type Session = z.infer<typeof SessionSchema>
57 57
58 -export const SessionSummarySchema = SessionSchema.pick({ id: true, name: true, status: true })
58 +export const SessionSummarySchema = SessionSchema.pick({
59 + id: true,
60 + name: true,
61 + status: true,
62 + createdAt: true,
63 +})
59 64 export type SessionSummary = z.infer<typeof SessionSummarySchema>
60 65
61 66 export const ContradictionSchema = z.object({