profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

Rebuild screens around the voice orb

commit e062b75

2 changed files with +824 and −304

Jump to a changed file
  1. client/src/App.css +685 −222
  2. client/src/App.tsx +139 −82
modified client/src/App.css +685 −222
@@ -1,393 +1,856 @@
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;
33 32 }
34 33
35 -.create-session-form label {
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);
49 +}
50 +
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
124 +.session-list {
125 + max-width: 400px;
126 + margin: 0 auto;
127 + animation: rise 0.7s 0.22s ease both;
128 +}
129 +
130 +.session-list h2 {
131 + font-size: 13px;
132 + font-family: var(--sans);
133 + font-weight: 500;
134 + text-transform: uppercase;
135 + letter-spacing: 0.12em;
136 + color: var(--ink-soft);
137 + margin-bottom: 10px;
138 +}
139 +
66 140 .session-list ul {
67 141 list-style: none;
68 142 padding: 0;
69 - margin: 12px 0 0;
143 + margin: 0;
70 144 display: flex;
71 145 flex-direction: column;
72 - gap: 8px;
73 146 }
74 147
75 148 .session-item {
76 149 width: 100%;
77 150 display: flex;
78 151 justify-content: space-between;
79 152 align-items: center;
80 - padding: 12px 16px;
81 - border: 1px solid var(--border);
82 - border-radius: 8px;
83 - background: var(--bg);
153 + padding: 13px 4px;
154 + border: none;
155 + border-bottom: 1px solid var(--line);
156 + background: transparent;
84 157 cursor: pointer;
85 - color: var(--text-h);
158 + color: var(--ink);
86 159 text-align: left;
160 + font-size: 16px;
161 + transition: padding 0.25s ease;
87 162 }
88 163
89 164 .session-item:hover {
90 - border-color: var(--accent);
165 + padding-left: 10px;
166 +}
167 +
168 +.session-item:hover .session-name {
169 + color: var(--clay-deep);
170 +}
171 +
172 +.session-name {
173 + transition: color 0.25s ease;
91 174 }
92 175
93 176 .session-status {
94 - font-size: 12px;
95 - padding: 2px 8px;
96 - border-radius: 999px;
97 - background: var(--bg-alt);
177 + font-size: 11px;
98 178 text-transform: uppercase;
99 - letter-spacing: 0.04em;
179 + letter-spacing: 0.1em;
180 + color: var(--ink-soft);
100 181 }
101 182
102 183 .session-status.status-done {
103 - color: var(--accent);
184 + color: var(--moss);
104 185 }
105 186
106 -/* Interview screen */
187 +/* ---------- Topbar ---------- */
107 188
108 -.interview-header {
189 +.topbar {
109 190 display: flex;
110 - justify-content: space-between;
111 191 align-items: center;
112 192 gap: 16px;
193 + margin-bottom: 22px;
194 +}
195 +
196 +.back-link {
197 + background: none;
198 + border: none;
199 + color: var(--ink-soft);
200 + cursor: pointer;
201 + padding: 0;
202 + font-size: 14px;
203 + transition: color 0.2s ease;
204 +}
205 +
206 +.back-link:hover {
207 + color: var(--clay-deep);
208 +}
209 +
210 +.topbar-title {
211 + flex: 1;
212 + text-align: center;
213 + font-family: var(--serif);
214 + font-size: 17px;
215 + color: var(--ink);
216 + overflow: hidden;
217 + text-overflow: ellipsis;
218 + white-space: nowrap;
113 219 }
114 220
115 221 .tts-toggle {
116 222 display: flex;
117 223 align-items: center;
118 - gap: 6px;
119 - font-size: 14px;
120 - color: var(--text);
121 - white-space: nowrap;
224 + gap: 8px;
225 + font-size: 13px;
226 + color: var(--ink-soft);
227 + cursor: pointer;
228 + user-select: none;
122 229 }
123 230
124 -.push-to-talk {
125 - margin-bottom: 12px;
231 +.tts-toggle input {
232 + position: absolute;
233 + opacity: 0;
234 + pointer-events: none;
126 235 }
127 236
128 -.record-button {
129 - width: 100%;
130 - padding: 14px;
131 - border-radius: 8px;
132 - border: 1px solid var(--border);
133 - background: var(--bg-alt);
134 - color: var(--text-h);
135 - cursor: pointer;
136 - font-size: 15px;
237 +.tts-track {
238 + width: 34px;
239 + height: 20px;
240 + border-radius: 999px;
241 + background: var(--line);
242 + position: relative;
243 + transition: background 0.25s ease;
244 + flex-shrink: 0;
137 245 }
138 246
139 -.record-button.recording {
140 - background: var(--danger);
141 - color: white;
142 - border-color: var(--danger);
247 +.tts-track::after {
248 + content: '';
249 + position: absolute;
250 + top: 3px;
251 + left: 3px;
252 + width: 14px;
253 + height: 14px;
254 + border-radius: 50%;
255 + background: var(--card);
256 + box-shadow: 0 1px 3px rgba(44, 38, 32, 0.25);
257 + transition: transform 0.25s cubic-bezier(0.34, 1.4, 0.64, 1);
143 258 }
144 259
145 -.record-button:disabled {
146 - opacity: 0.6;
147 - cursor: default;
260 +.tts-toggle input:checked + .tts-track {
261 + background: var(--clay);
148 262 }
149 263
150 -.back-link {
151 - background: none;
152 - border: none;
153 - color: var(--accent);
154 - cursor: pointer;
264 +.tts-toggle input:checked + .tts-track::after {
265 + transform: translateX(14px);
266 +}
267 +
268 +/* ---------- Coverage strip ---------- */
269 +
270 +.coverage-panel {
271 + list-style: none;
155 272 padding: 0;
156 - margin-bottom: 16px;
157 - font-size: 14px;
273 + margin: 0 0 30px;
274 + display: flex;
275 + flex-wrap: wrap;
276 + justify-content: center;
277 + gap: 6px 14px;
278 +}
279 +
280 +.coverage-item {
281 + display: flex;
282 + align-items: center;
283 + gap: 6px;
284 + font-size: 12px;
285 + letter-spacing: 0.04em;
286 + color: var(--ink-soft);
287 + transition: color 0.4s ease;
158 288 }
159 289
160 -.interview-layout {
161 - display: grid;
162 - grid-template-columns: 1fr 220px;
163 - gap: 24px;
164 - align-items: start;
290 +.coverage-clear {
291 + color: var(--ink);
165 292 }
166 293
167 -@media (max-width: 720px) {
168 - .interview-layout {
169 - grid-template-columns: 1fr;
170 - }
294 +.coverage-dot {
295 + width: 7px;
296 + height: 7px;
297 + border-radius: 50%;
298 + background: transparent;
299 + border: 1.5px solid var(--line);
300 + flex-shrink: 0;
301 + transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s ease;
171 302 }
172 303
173 -.question-card {
174 - padding: 16px 20px;
175 - border-radius: 10px;
176 - background: var(--accent-bg);
177 - margin-bottom: 20px;
304 +.coverage-partial .coverage-dot {
305 + border-color: var(--amber);
306 + background: color-mix(in srgb, var(--amber) 45%, transparent);
178 307 }
179 308
180 -.question-label {
181 - display: block;
182 - font-size: 12px;
183 - text-transform: uppercase;
184 - letter-spacing: 0.06em;
185 - color: var(--accent);
186 - margin-bottom: 6px;
309 +.coverage-clear .coverage-dot {
310 + border-color: var(--moss);
311 + background: var(--moss);
312 + transform: scale(1.1);
313 +}
314 +
315 +/* ---------- Stage ---------- */
316 +
317 +.stage {
318 + display: flex;
319 + flex-direction: column;
320 + align-items: center;
321 + text-align: center;
322 +}
323 +
324 +.question {
325 + max-width: 520px;
326 + margin: 26px 0 8px;
327 + animation: rise 0.5s ease both;
187 328 }
188 329
189 330 .question-text {
190 - font-size: 18px;
191 - color: var(--text-h);
331 + font-family: var(--serif);
332 + font-size: 26px;
333 + line-height: 1.35;
334 + font-weight: 450;
335 + color: var(--ink);
336 + text-wrap: balance;
192 337 }
193 338
194 -.transcript {
339 +.status-caption {
340 + font-size: 13px;
341 + letter-spacing: 0.08em;
342 + color: var(--ink-soft);
343 + margin: 4px 0 18px;
344 + min-height: 20px;
345 +}
346 +
347 +/* ---------- Orb ---------- */
348 +
349 +.orb-wrap {
350 + position: relative;
351 + width: 148px;
352 + height: 148px;
353 + margin-top: 10px;
354 + flex-shrink: 0;
355 +}
356 +
357 +.orb {
358 + position: absolute;
359 + inset: 0;
360 + border-radius: 50%;
361 + background:
362 + radial-gradient(circle at 33% 28%, #f9ddbd 0%, #eeb289 42%, #cd7a54 78%, #b4593a 100%);
363 + box-shadow:
364 + inset -8px -12px 24px rgba(140, 60, 30, 0.28),
365 + inset 6px 8px 18px rgba(255, 245, 225, 0.55),
366 + 0 18px 40px -18px rgba(140, 70, 40, 0.5);
367 + animation: breathe 5.2s ease-in-out infinite;
368 +}
369 +
370 +.orb-face {
371 + position: absolute;
372 + inset: 0;
195 373 display: flex;
196 374 flex-direction: column;
197 - gap: 12px;
198 - margin-bottom: 20px;
375 + align-items: center;
376 + justify-content: center;
377 + gap: 10px;
199 378 }
200 379
201 -.transcript-empty {
202 - color: var(--text);
203 - opacity: 0.7;
204 - margin-bottom: 20px;
380 +.orb-eyes {
381 + display: flex;
382 + gap: 26px;
205 383 }
206 384
207 -.segment {
208 - padding: 10px 14px;
209 - border-radius: 8px;
210 - border: 1px solid var(--border);
385 +.orb-eye {
386 + width: 9px;
387 + height: 22px;
388 + border-radius: 6px;
389 + background: #38261c;
390 + animation: blink 5.4s ease-in-out infinite;
391 + transition: transform 0.3s ease;
211 392 }
212 393
213 -.segment-user {
214 - background: var(--bg);
394 +.orb-mouth {
395 + display: flex;
396 + align-items: center;
397 + gap: 3px;
398 + height: 14px;
399 + opacity: 0;
400 + transition: opacity 0.25s ease;
215 401 }
216 402
217 -.segment-interviewer {
218 - background: var(--bg-alt);
403 +.orb-mouth-bar {
404 + width: 3.5px;
405 + height: 4px;
406 + border-radius: 2px;
407 + background: #38261c;
219 408 }
220 409
221 -.segment-speaker {
222 - display: block;
410 +/* level ring, driven by mic input via JS */
411 +.orb-level-ring {
412 + position: absolute;
413 + inset: -14px;
414 + border-radius: 50%;
415 + border: 2px solid var(--clay);
416 + opacity: 0;
417 + transition: opacity 0.3s ease;
418 + will-change: transform, opacity;
419 +}
420 +
421 +.orb-ripple {
422 + position: absolute;
423 + inset: -6px;
424 + border-radius: 50%;
425 + border: 1.5px solid var(--clay);
426 + opacity: 0;
427 +}
428 +
429 +/* states */
430 +
431 +.orb-listening .orb-ripple-1 {
432 + animation: ripple 1.9s ease-out infinite;
433 +}
434 +
435 +.orb-listening .orb-ripple-2 {
436 + animation: ripple 1.9s 0.65s ease-out infinite;
437 +}
438 +
439 +.orb-listening .orb-eye {
440 + transform: scaleY(1.12);
441 +}
442 +
443 +.orb-thinking .orb {
444 + animation: bob 1.6s ease-in-out infinite;
445 +}
446 +
447 +.orb-thinking .orb-eye {
448 + transform: scaleY(0.22) translateY(6px);
449 + animation: none;
450 +}
451 +
452 +.orb-speaking .orb-mouth {
453 + opacity: 1;
454 +}
455 +
456 +.orb-speaking .orb-mouth-bar {
457 + animation: talk 0.55s ease-in-out infinite;
458 +}
459 +
460 +.orb-speaking .orb-mouth-bar:nth-child(2) {
461 + animation-delay: 0.12s;
462 +}
463 +
464 +.orb-speaking .orb-mouth-bar:nth-child(3) {
465 + animation-delay: 0.24s;
466 +}
467 +
468 +.orb-speaking .orb-mouth-bar:nth-child(4) {
469 + animation-delay: 0.36s;
470 +}
471 +
472 +/* ---------- Waveform ---------- */
473 +
474 +.waveform {
475 + display: flex;
476 + align-items: center;
477 + justify-content: center;
478 + gap: 3px;
479 + height: 44px;
480 + margin-top: 14px;
481 +}
482 +
483 +.waveform-bar {
484 + width: 2.5px;
485 + height: 38px;
486 + border-radius: 2px;
487 + background: var(--clay);
488 + transform: scaleY(0.08);
489 + transform-origin: center;
490 + transition: background 0.3s ease, opacity 0.3s ease;
491 + will-change: transform;
492 +}
493 +
494 +.waveform-idle .waveform-bar {
495 + opacity: 0.28;
496 + background: var(--ink-soft);
497 +}
498 +
499 +.waveform-simulated .waveform-bar {
500 + animation: wave 0.8s ease-in-out infinite alternate;
501 +}
502 +
503 +/* ---------- Mic + answer ---------- */
504 +
505 +.push-to-talk {
506 + display: flex;
507 + flex-direction: column;
508 + align-items: center;
509 + gap: 8px;
510 + margin-bottom: 18px;
511 +}
512 +
513 +.mic-button {
514 + width: 72px;
515 + height: 72px;
516 + border-radius: 50%;
517 + border: 1.5px solid var(--line);
518 + background: var(--card);
519 + color: var(--ink);
520 + cursor: pointer;
521 + display: flex;
522 + align-items: center;
523 + justify-content: center;
524 + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease,
525 + transform 0.2s ease, box-shadow 0.3s ease;
526 + box-shadow: 0 10px 24px -14px rgba(44, 38, 32, 0.4);
527 + touch-action: none;
528 +}
529 +
530 +.mic-button:hover:not(:disabled) {
531 + border-color: var(--clay);
532 + color: var(--clay-deep);
533 + transform: translateY(-1px);
534 +}
535 +
536 +.mic-button.recording {
537 + background: var(--clay);
538 + border-color: var(--clay);
539 + color: var(--card);
540 + transform: scale(1.08);
541 + box-shadow: 0 0 0 10px rgba(196, 106, 74, 0.16), 0 14px 30px -14px rgba(169, 85, 56, 0.7);
542 +}
543 +
544 +.mic-button:disabled {
545 + opacity: 0.5;
546 + cursor: default;
547 +}
548 +
549 +.mic-hint {
223 550 font-size: 12px;
224 - color: var(--text);
225 - opacity: 0.7;
226 - margin-bottom: 4px;
551 + letter-spacing: 0.06em;
552 + color: var(--ink-soft);
227 553 }
228 554
229 555 .answer-form {
230 556 display: flex;
231 - gap: 8px;
557 + gap: 10px;
558 + width: 100%;
559 + max-width: 440px;
232 560 }
233 561
234 562 .answer-form input {
235 563 flex: 1;
236 - padding: 10px 12px;
237 - border: 1px solid var(--border);
238 - border-radius: 6px;
239 - background: var(--bg);
240 - color: var(--text-h);
564 + padding: 11px 16px;
565 + border: 1.5px solid var(--line);
566 + border-radius: 999px;
567 + background: var(--card);
568 + color: var(--ink);
569 + font-size: 15px;
570 + transition: border-color 0.25s ease;
571 +}
572 +
573 +.answer-form input:focus {
574 + outline: none;
575 + border-color: var(--clay);
241 576 }
242 577
243 -.answer-form button {
244 - padding: 10px 16px;
578 +.answer-form input::placeholder {
579 + color: var(--ink-soft);
580 + opacity: 0.6;
581 +}
582 +
583 +.done-button {
584 + margin-top: 14px;
585 + background: none;
245 586 border: none;
246 - border-radius: 6px;
587 + color: var(--ink-soft);
588 + font-size: 13px;
589 + letter-spacing: 0.04em;
247 590 cursor: pointer;
591 + text-decoration: underline;
592 + text-underline-offset: 4px;
593 + text-decoration-color: var(--line);
594 + transition: color 0.25s ease, text-decoration-color 0.25s ease;
248 595 }
249 596
250 -.answer-form button[type='submit'] {
251 - background: var(--accent);
252 - color: white;
597 +.done-button:hover:not(:disabled) {
598 + color: var(--clay-deep);
599 + text-decoration-color: var(--clay);
253 600 }
254 601
255 -.answer-form .done-button {
256 - background: var(--bg-alt);
257 - color: var(--text-h);
258 - border: 1px solid var(--border);
602 +.done-button:disabled {
603 + opacity: 0.5;
604 + cursor: default;
259 605 }
260 606
261 -.answer-form button:disabled {
262 - opacity: 0.6;
263 - cursor: default;
607 +/* ---------- Finish panels ---------- */
608 +
609 +.interview-done {
610 + font-family: var(--serif);
611 + font-size: 24px;
612 + margin: 24px 0 6px;
613 +}
614 +
615 +.finish-panels {
616 + display: flex;
617 + flex-direction: column;
618 + gap: 16px;
619 + width: 100%;
620 + max-width: 460px;
621 + margin-top: 18px;
622 + text-align: left;
264 623 }
265 624
266 -.generate-panel {
267 - margin-top: 20px;
268 - padding: 16px 20px;
269 - border: 1px solid var(--border);
270 - border-radius: 10px;
625 +.generate-panel,
626 +.blocker-import {
627 + padding: 20px 22px;
628 + border: 1px solid var(--line);
629 + border-radius: 16px;
630 + background: var(--card);
631 + box-shadow: 0 12px 30px -24px rgba(44, 38, 32, 0.5);
271 632 }
272 633
273 -.generate-panel h2 {
274 - font-size: 16px;
275 - margin-bottom: 12px;
634 +.generate-panel h2,
635 +.blocker-import h2 {
636 + font-size: 17px;
637 + margin-bottom: 10px;
276 638 }
277 639
278 -.generate-panel button {
279 - padding: 8px 14px;
280 - border-radius: 6px;
640 +.generate-panel p,
641 +.blocker-import p {
642 + font-size: 14px;
643 + color: var(--ink-soft);
644 + margin-bottom: 10px;
645 +}
646 +
647 +.generate-panel button,
648 +.blocker-import button {
649 + padding: 9px 18px;
650 + border-radius: 999px;
281 651 border: none;
282 - background: var(--accent);
283 - color: white;
652 + background: var(--clay);
653 + color: var(--card);
654 + font-size: 14px;
284 655 cursor: pointer;
285 656 margin-right: 8px;
657 + transition: background 0.25s ease;
286 658 }
287 659
288 -.generate-panel button:disabled {
289 - opacity: 0.6;
290 - cursor: default;
660 +.generate-panel button:hover:not(:disabled),
661 +.blocker-import button:hover:not(:disabled) {
662 + background: var(--clay-deep);
291 663 }
292 664
293 -.generate-confirm,
294 -.generate-overwrite {
295 - margin-bottom: 12px;
665 +.generate-panel button:disabled,
666 +.blocker-import button:disabled {
667 + opacity: 0.55;
668 + cursor: default;
296 669 }
297 670
298 671 .generate-confirm button:last-child,
299 -.generate-overwrite button {
300 - background: var(--bg-alt);
301 - color: var(--text-h);
302 - border: 1px solid var(--border);
672 +.generate-overwrite button,
673 +.blocker-import button {
674 + background: transparent;
675 + color: var(--ink);
676 + border: 1.5px solid var(--line);
677 +}
678 +
679 +.generate-confirm button:last-child:hover:not(:disabled),
680 +.generate-overwrite button:hover:not(:disabled),
681 +.blocker-import button:hover:not(:disabled) {
682 + background: var(--paper-deep);
683 + border-color: var(--clay);
303 684 }
304 685
305 686 .generate-result ul {
306 687 margin: 8px 0 0;
307 688 padding-left: 20px;
689 + font-size: 14px;
690 + color: var(--ink);
308 691 }
309 692
310 693 .generate-warnings {
311 694 margin-top: 8px;
312 - color: var(--text);
313 - opacity: 0.85;
695 + font-size: 14px;
696 + color: var(--ink-soft);
314 697 }
315 698
316 -.blocker-import {
317 - margin-top: 20px;
318 - padding: 16px 20px;
319 - border: 1px solid var(--border);
320 - border-radius: 10px;
699 +.generate-panel .error,
700 +.blocker-import .error {
701 + text-align: left;
321 702 }
322 703
323 -.blocker-import h2 {
324 - font-size: 16px;
325 - margin-bottom: 8px;
704 +/* ---------- Transcript ---------- */
705 +
706 +.transcript-section {
707 + margin-top: 46px;
708 + animation: rise 0.6s ease both;
326 709 }
327 710
328 -.blocker-import button {
329 - padding: 8px 14px;
330 - border-radius: 6px;
331 - border: 1px solid var(--border);
332 - background: var(--bg-alt);
333 - color: var(--text-h);
334 - cursor: pointer;
335 - margin-top: 4px;
711 +.transcript-title {
712 + font-size: 12px;
713 + font-family: var(--sans);
714 + font-weight: 500;
715 + text-transform: uppercase;
716 + letter-spacing: 0.14em;
717 + color: var(--ink-soft);
718 + margin-bottom: 12px;
336 719 }
337 720
338 -.blocker-import button:disabled {
339 - opacity: 0.6;
340 - cursor: default;
721 +.transcript {
722 + display: flex;
723 + flex-direction: column;
724 + gap: 14px;
725 + max-height: 300px;
726 + overflow-y: auto;
727 + padding-right: 8px;
728 + mask-image: linear-gradient(to bottom, transparent 0, black 18px);
341 729 }
342 730
343 -.interview-done {
344 - padding: 12px 16px;
345 - border-radius: 8px;
346 - background: var(--accent-bg);
347 - color: var(--text-h);
731 +.segment {
732 + max-width: 82%;
733 + animation: rise 0.35s ease both;
348 734 }
349 735
350 -.interview-sidebar h2 {
351 - font-size: 14px;
736 +.segment-user {
737 + align-self: flex-end;
738 + text-align: right;
739 +}
740 +
741 +.segment-interviewer {
742 + align-self: flex-start;
743 + border-left: 2px solid var(--clay-soft);
744 + padding-left: 12px;
745 +}
746 +
747 +.segment-speaker {
748 + display: block;
749 + font-size: 11px;
352 750 text-transform: uppercase;
353 - letter-spacing: 0.06em;
354 - color: var(--text);
355 - opacity: 0.8;
751 + letter-spacing: 0.1em;
752 + color: var(--ink-soft);
753 + margin-bottom: 3px;
356 754 }
357 755
358 -.coverage-panel {
359 - list-style: none;
360 - padding: 0;
361 - margin: 0;
362 - display: flex;
363 - flex-direction: column;
364 - gap: 8px;
756 +.segment-text {
757 + font-size: 15px;
758 + color: var(--ink);
365 759 }
366 760
367 -.coverage-item {
368 - display: flex;
369 - align-items: center;
370 - gap: 8px;
371 - font-size: 14px;
372 - color: var(--text-h);
761 +.segment-user .segment-text {
762 + display: inline-block;
763 + background: var(--paper-deep);
764 + border-radius: 14px 14px 4px 14px;
765 + padding: 8px 14px;
373 766 }
374 767
375 -.coverage-dot {
376 - width: 10px;
377 - height: 10px;
378 - border-radius: 50%;
379 - background: var(--border);
380 - flex-shrink: 0;
768 +/* ---------- Animations ---------- */
769 +
770 +@keyframes breathe {
771 + 0%,
772 + 100% {
773 + transform: scale(1);
774 + }
775 + 50% {
776 + transform: scale(1.035);
777 + }
381 778 }
382 779
383 -.coverage-missing .coverage-dot {
384 - background: var(--border);
780 +@keyframes blink {
781 + 0%,
782 + 4%,
783 + 100% {
784 + transform: scaleY(1);
785 + }
786 + 2% {
787 + transform: scaleY(0.08);
788 + }
385 789 }
386 790
387 -.coverage-partial .coverage-dot {
388 - background: #e0a83f;
791 +@keyframes ripple {
792 + 0% {
793 + transform: scale(1);
794 + opacity: 0.45;
795 + }
796 + 100% {
797 + transform: scale(1.42);
798 + opacity: 0;
799 + }
389 800 }
390 801
391 -.coverage-clear .coverage-dot {
392 - background: #2fa869;
802 +@keyframes bob {
803 + 0%,
804 + 100% {
805 + transform: translateY(0) scale(1);
806 + }
807 + 50% {
808 + transform: translateY(-4px) scale(1.015);
809 + }
810 +}
811 +
812 +@keyframes talk {
813 + 0%,
814 + 100% {
815 + height: 4px;
816 + }
817 + 50% {
818 + height: 13px;
819 + }
820 +}
821 +
822 +@keyframes wave {
823 + 0% {
824 + transform: scaleY(0.12);
825 + }
826 + 100% {
827 + transform: scaleY(0.85);
828 + }
829 +}
830 +
831 +@keyframes rise {
832 + from {
833 + opacity: 0;
834 + transform: translateY(10px);
835 + }
836 + to {
837 + opacity: 1;
838 + transform: translateY(0);
839 + }
840 +}
841 +
842 +@media (prefers-reduced-motion: reduce) {
843 + .orb,
844 + .orb-eye,
845 + .orb-ripple,
846 + .orb-mouth-bar,
847 + .waveform-bar,
848 + .hero,
849 + .create-form,
850 + .session-list,
851 + .question,
852 + .segment,
853 + .transcript-section {
854 + animation: none !important;
855 + }
393 856 }
modified client/src/App.tsx +139 −82
@@ -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'
@@ -8,16 +8,25 @@import { GeneratePanel } from './components/GeneratePanel'
8 8 import { PushToTalkButton } from './components/PushToTalkButton'
9 9 import { QuestionCard } from './components/QuestionCard'
10 10 import { Transcript } from './components/Transcript'
11 -import { speak } from './tts'
11 +import { VoiceOrb, type VoiceState } from './components/VoiceOrb'
12 +import { Waveform, type WaveformMode } from './components/Waveform'
13 +import { speak, stopSpeaking } from './tts'
12 14
13 15 function latestQuestion(session: Session): string | null {
14 16 for (let i = session.segments.length - 1; i >= 0; i--) {
15 17 const segment = session.segments[i]
16 - if (segment.speaker === 'interviewer') return segment.text
18 + if (segment && segment.speaker === 'interviewer') return segment.text
17 19 }
18 20 return null
19 21 }
20 22
23 +const CAPTIONS: Record<VoiceState, string> = {
24 + idle: 'hold the mic or press space to answer',
25 + listening: 'listening…',
26 + thinking: 'thinking…',
27 + speaking: 'speaking…',
28 +}
29 +
21 30 function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
22 31 const [sessions, setSessions] = useState<SessionSummary[]>([])
23 32 const [name, setName] = useState('')
@@ -44,46 +53,51 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
44 53 }
45 54
46 55 return (
47 - <div className="screen session-list-screen">
48 - <h1>VoiceTask</h1>
49 - <p className="subtitle">Talk through what you want to build. VoiceTask interviews you and writes the spec.</p>
56 + <div className="screen home-screen">
57 + <div className="hero">
58 + <VoiceOrb state="idle" />
59 + <h1 className="wordmark">
60 + voicetask<span className="wordmark-dot">.</span>
61 + </h1>
62 + <p className="tagline">Talk through what you want to build. It interviews you, then writes the spec.</p>
63 + </div>
50 64
51 - <form className="create-session-form" onSubmit={handleCreate}>
52 - <h2>Start a new interview</h2>
65 + <form className="create-form" onSubmit={handleCreate}>
53 66 <label>
54 - Project name
55 - <input value={name} onChange={(e) => setName(e.target.value)} required />
67 + <span>Project name</span>
68 + <input value={name} onChange={(e) => setName(e.target.value)} placeholder="my next thing" required />
56 69 </label>
57 70 <label>
58 - Target directory
71 + <span>Target directory</span>
59 72 <input
60 73 value={targetDir}
61 74 onChange={(e) => setTargetDir(e.target.value)}
62 75 placeholder="/path/to/project"
63 76 required
64 77 />
65 78 </label>
66 - <button type="submit" disabled={creating}>
67 - {creating ? 'Starting…' : 'Start interview'}
79 + <button type="submit" className="btn-primary" disabled={creating}>
80 + {creating ? 'Starting…' : 'Start talking'}
68 81 </button>
69 82 </form>
70 83
71 84 {error && <p className="error">{error}</p>}
72 85
73 - <div className="session-list">
74 - <h2>Existing sessions</h2>
75 - {sessions.length === 0 && <p className="muted">No sessions yet.</p>}
76 - <ul>
77 - {sessions.map((session) => (
78 - <li key={session.id}>
79 - <button type="button" className="session-item" onClick={() => onOpen(session.id)}>
80 - <span className="session-name">{session.name}</span>
81 - <span className={`session-status status-${session.status}`}>{session.status}</span>
82 - </button>
83 - </li>
84 - ))}
85 - </ul>
86 - </div>
86 + {sessions.length > 0 && (
87 + <div className="session-list">
88 + <h2>Earlier sessions</h2>
89 + <ul>
90 + {sessions.map((session) => (
91 + <li key={session.id}>
92 + <button type="button" className="session-item" onClick={() => onOpen(session.id)}>
93 + <span className="session-name">{session.name}</span>
94 + <span className={`session-status status-${session.status}`}>{session.status}</span>
95 + </button>
96 + </li>
97 + ))}
98 + </ul>
99 + </div>
100 + )}
87 101 </div>
88 102 )
89 103 }
@@ -93,8 +107,11 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
93 107 const [answerText, setAnswerText] = useState('')
94 108 const [error, setError] = useState<string | null>(null)
95 109 const [submitting, setSubmitting] = useState(false)
96 - const [ttsEnabled, setTtsEnabled] = useState(false)
110 + const [recording, setRecording] = useState(false)
111 + const [speaking, setSpeaking] = useState(false)
112 + const [ttsEnabled, setTtsEnabled] = useState(true)
97 113 const lastSpokenQuestion = useRef<string | null>(null)
114 + const levelRef = useRef(0)
98 115
99 116 function refresh() {
100 117 return api.getSession(sessionId).then(setSession)
@@ -105,14 +122,38 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
105 122 }, [sessionId])
106 123
107 124 useEffect(() => {
108 - if (!ttsEnabled || !session) return
125 + if (!ttsEnabled) {
126 + stopSpeaking()
127 + setSpeaking(false)
128 + return
129 + }
130 + if (!session) return
109 131 const question = latestQuestion(session)
110 132 if (question && question !== lastSpokenQuestion.current) {
111 133 lastSpokenQuestion.current = question
112 - speak(question)
134 + speak(question, {
135 + onStart: () => setSpeaking(true),
136 + onEnd: () => setSpeaking(false),
137 + })
113 138 }
114 139 }, [ttsEnabled, session])
115 140
141 + useEffect(() => {
142 + return () => stopSpeaking()
143 + }, [])
144 +
145 + const handleRecordingChange = useCallback((isRecording: boolean) => {
146 + setRecording(isRecording)
147 + if (isRecording) {
148 + stopSpeaking()
149 + setSpeaking(false)
150 + }
151 + }, [])
152 +
153 + const handleLevel = useCallback((level: number) => {
154 + levelRef.current = level
155 + }, [])
156 +
116 157 async function submit(text: string) {
117 158 if (!text.trim()) return
118 159 setError(null)
@@ -149,76 +190,92 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
149 190 if (!session) {
150 191 return (
151 192 <div className="screen interview-screen">
152 - <button type="button" className="back-link" onClick={onBack}>
153 - ← Back to sessions
154 - </button>
155 - {error ? <p className="error">{error}</p> : <p>Loading…</p>}
193 + <header className="topbar">
194 + <button type="button" className="back-link" onClick={onBack}>
195 + &larr; Sessions
196 + </button>
197 + </header>
198 + {error ? <p className="error">{error}</p> : <p className="muted">Loading&hellip;</p>}
156 199 </div>
157 200 )
158 201 }
159 202
203 + const interviewing = session.status === 'interviewing'
204 + const voiceState: VoiceState = recording
205 + ? 'listening'
206 + : submitting
207 + ? 'thinking'
208 + : speaking
209 + ? 'speaking'
210 + : 'idle'
211 + const waveformMode: WaveformMode =
212 + voiceState === 'listening' ? 'live' : voiceState === 'speaking' ? 'simulated' : 'idle'
213 +
160 214 return (
161 215 <div className="screen interview-screen">
162 - <button type="button" className="back-link" onClick={onBack}>
163 - ← Back to sessions
164 - </button>
165 - <div className="interview-header">
166 - <h1>{session.name}</h1>
216 + <header className="topbar">
217 + <button type="button" className="back-link" onClick={onBack}>
218 + &larr; Sessions
219 + </button>
220 + <span className="topbar-title">{session.name}</span>
167 221 <label className="tts-toggle">
168 222 <input
169 223 type="checkbox"
170 224 checked={ttsEnabled}
171 225 onChange={(e) => setTtsEnabled(e.target.checked)}
172 226 />
173 - Read questions aloud
227 + <span className="tts-track" aria-hidden="true" />
228 + voice
174 229 </label>
175 - </div>
230 + </header>
176 231
177 - <div className="interview-layout">
178 - <div className="interview-main">
179 - <QuestionCard question={latestQuestion(session)} />
180 - <Transcript segments={session.segments} />
181 -
182 - {session.status === 'interviewing' ? (
183 - <>
184 - <PushToTalkButton disabled={submitting} onRecorded={(blob) => void submitAudio(blob)} />
185 - <form className="answer-form" onSubmit={handleSubmit}>
186 - <input
187 - value={answerText}
188 - onChange={(e) => setAnswerText(e.target.value)}
189 - placeholder="Type your answer…"
190 - disabled={submitting}
191 - autoFocus
192 - />
193 - <button type="submit" disabled={submitting || !answerText.trim()}>
194 - Send
195 - </button>
196 - <button
197 - type="button"
198 - className="done-button"
199 - disabled={submitting}
200 - onClick={() => submit('done')}
201 - >
202 - Done
203 - </button>
204 - </form>
205 - </>
206 - ) : (
207 - <>
208 - <p className="interview-done">Interview complete.</p>
232 + <CoveragePanel coverage={session.coverage} />
233 +
234 + <main className="stage">
235 + <VoiceOrb state={voiceState} levelRef={levelRef} />
236 +
237 + {interviewing ? (
238 + <>
239 + <QuestionCard question={latestQuestion(session)} />
240 + <Waveform mode={waveformMode} levelRef={levelRef} />
241 + <p className="status-caption">{CAPTIONS[voiceState]}</p>
242 +
243 + <PushToTalkButton
244 + disabled={submitting}
245 + onRecorded={(blob) => void submitAudio(blob)}
246 + onRecordingChange={handleRecordingChange}
247 + onLevel={handleLevel}
248 + />
249 +
250 + <form className="answer-form" onSubmit={handleSubmit}>
251 + <input
252 + value={answerText}
253 + onChange={(e) => setAnswerText(e.target.value)}
254 + placeholder="or type your answer…"
255 + disabled={submitting}
256 + />
257 + <button type="submit" className="btn-primary" disabled={submitting || !answerText.trim()}>
258 + Send
259 + </button>
260 + </form>
261 + <button type="button" className="done-button" disabled={submitting} onClick={() => submit('done')}>
262 + I'm done, wrap it up
263 + </button>
264 + </>
265 + ) : (
266 + <>
267 + <p className="interview-done">Interview complete.</p>
268 + <div className="finish-panels">
209 269 <GeneratePanel sessionId={session.id} coverage={session.coverage} />
210 270 <BlockerImport sessionId={session.id} onImported={refresh} />
211 - </>
212 - )}
271 + </div>
272 + </>
273 + )}
213 274
214 - {error && <p className="error">{error}</p>}
215 - </div>
275 + {error && <p className="error">{error}</p>}
276 + </main>
216 277
217 - <aside className="interview-sidebar">
218 - <h2>Coverage</h2>
219 - <CoveragePanel coverage={session.coverage} />
220 - </aside>
221 - </div>
278 + <Transcript segments={session.segments} />
222 279 </div>
223 280 )
224 281 }