Commit
Simplify interview controls
commit
8d300df
6 changed files with +307 and −96
Jump to a changed file
- client/src/App.css +141 −36
- client/src/App.tsx +15 −9
- client/src/components/CoveragePanel.tsx +40 −7
- client/src/components/PushToTalkButton.tsx +109 −42
- client/src/components/Transcript.tsx +1 −1
- spec/TASKS.md +1 −1
modified client/src/App.css +141 −36
| @@ -910,58 +910,146 @@ | ||
| 910 | 910 | transform: translateX(14px); |
| 911 | 911 | } |
| 912 | 912 | |
| 913 | -/* ---------- Coverage strip ---------- */ | |
| 913 | +.coverage-card { | |
| 914 | + margin-bottom: 30px; | |
| 915 | + padding: 18px; | |
| 916 | + border: 1px solid var(--line); | |
| 917 | + border-radius: 18px; | |
| 918 | + background: color-mix(in srgb, var(--card) 88%, var(--clay-soft)); | |
| 919 | +} | |
| 920 | + | |
| 921 | +.coverage-heading { | |
| 922 | + display: flex; | |
| 923 | + align-items: center; | |
| 924 | + justify-content: space-between; | |
| 925 | + gap: 20px; | |
| 926 | +} | |
| 927 | + | |
| 928 | +.coverage-kicker { | |
| 929 | + margin-bottom: 3px; | |
| 930 | + color: var(--clay); | |
| 931 | + font-size: 10px; | |
| 932 | + font-weight: 700; | |
| 933 | + letter-spacing: 0.12em; | |
| 934 | + text-transform: uppercase; | |
| 935 | +} | |
| 936 | + | |
| 937 | +.coverage-heading h2 { | |
| 938 | + margin: 0; | |
| 939 | + font-family: var(--sans); | |
| 940 | + font-size: 16px; | |
| 941 | + font-weight: 700; | |
| 942 | + letter-spacing: -0.02em; | |
| 943 | +} | |
| 944 | + | |
| 945 | +.coverage-fraction { | |
| 946 | + display: inline-flex; | |
| 947 | + align-items: center; | |
| 948 | + justify-content: center; | |
| 949 | + min-width: 45px; | |
| 950 | + height: 32px; | |
| 951 | + padding: 0 8px; | |
| 952 | + border-radius: 10px; | |
| 953 | + background: var(--ink); | |
| 954 | + color: var(--card); | |
| 955 | + font-family: var(--mono); | |
| 956 | + font-size: 12px; | |
| 957 | +} | |
| 958 | + | |
| 959 | +.coverage-track { | |
| 960 | + height: 7px; | |
| 961 | + margin-top: 14px; | |
| 962 | + overflow: hidden; | |
| 963 | + border-radius: 999px; | |
| 964 | + background: var(--paper-deep); | |
| 965 | +} | |
| 966 | + | |
| 967 | +.coverage-track > span { | |
| 968 | + display: block; | |
| 969 | + width: 0; | |
| 970 | + height: 100%; | |
| 971 | + border-radius: inherit; | |
| 972 | + background: var(--moss); | |
| 973 | + transition: width 0.45s ease; | |
| 974 | +} | |
| 914 | 975 | |
| 915 | 976 | .coverage-progress { |
| 916 | - font-size: 13px; | |
| 977 | + margin: 8px 0 14px; | |
| 917 | 978 | color: var(--ink-soft); |
| 918 | - text-align: center; | |
| 919 | - margin: 0 0 10px; | |
| 979 | + font-size: 12px; | |
| 920 | 980 | } |
| 921 | 981 | |
| 922 | 982 | .coverage-panel { |
| 923 | - list-style: none; | |
| 983 | + display: grid; | |
| 984 | + grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| 985 | + gap: 7px; | |
| 986 | + margin: 0; | |
| 924 | 987 | padding: 0; |
| 925 | - margin: 0 0 30px; | |
| 926 | - display: flex; | |
| 927 | - flex-wrap: wrap; | |
| 928 | - justify-content: center; | |
| 929 | - gap: 6px 14px; | |
| 988 | + list-style: none; | |
| 930 | 989 | } |
| 931 | 990 | |
| 932 | 991 | .coverage-item { |
| 933 | 992 | display: flex; |
| 934 | 993 | align-items: center; |
| 935 | - gap: 6px; | |
| 936 | - font-size: 12px; | |
| 937 | - letter-spacing: 0.04em; | |
| 994 | + gap: 8px; | |
| 995 | + min-width: 0; | |
| 996 | + padding: 8px 9px; | |
| 997 | + border: 1px solid var(--line); | |
| 998 | + border-radius: 10px; | |
| 999 | + background: var(--card); | |
| 938 | 1000 | color: var(--ink-soft); |
| 939 | - transition: color 0.4s ease; | |
| 1001 | + transition: color 0.3s ease, border-color 0.3s ease, background 0.3s ease; | |
| 940 | 1002 | } |
| 941 | 1003 | |
| 942 | -.coverage-clear { | |
| 1004 | +.coverage-item-copy { | |
| 1005 | + display: flex; | |
| 1006 | + flex-direction: column; | |
| 1007 | + min-width: 0; | |
| 1008 | +} | |
| 1009 | + | |
| 1010 | +.coverage-item-copy > span:first-child { | |
| 1011 | + overflow: hidden; | |
| 943 | 1012 | color: var(--ink); |
| 1013 | + font-size: 11px; | |
| 1014 | + font-weight: 650; | |
| 1015 | + text-overflow: ellipsis; | |
| 1016 | + white-space: nowrap; | |
| 1017 | +} | |
| 1018 | + | |
| 1019 | +.coverage-status { | |
| 1020 | + color: var(--ink-soft); | |
| 1021 | + font-size: 9px; | |
| 1022 | + font-weight: 600; | |
| 944 | 1023 | } |
| 945 | 1024 | |
| 946 | 1025 | .coverage-dot { |
| 947 | - width: 7px; | |
| 948 | - height: 7px; | |
| 1026 | + width: 8px; | |
| 1027 | + height: 8px; | |
| 1028 | + flex-shrink: 0; | |
| 1029 | + border: 1.5px solid var(--line); | |
| 949 | 1030 | border-radius: 50%; |
| 950 | 1031 | background: transparent; |
| 951 | - border: 1.5px solid var(--line); | |
| 952 | - flex-shrink: 0; | |
| 953 | - transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s ease; | |
| 1032 | + transition: background 0.3s ease, border-color 0.3s ease; | |
| 1033 | +} | |
| 1034 | + | |
| 1035 | +.coverage-partial { | |
| 1036 | + border-color: color-mix(in srgb, var(--amber) 38%, var(--line)); | |
| 1037 | + background: color-mix(in srgb, var(--amber) 6%, var(--card)); | |
| 954 | 1038 | } |
| 955 | 1039 | |
| 956 | 1040 | .coverage-partial .coverage-dot { |
| 957 | 1041 | border-color: var(--amber); |
| 958 | - background: color-mix(in srgb, var(--amber) 45%, transparent); | |
| 1042 | + background: color-mix(in srgb, var(--amber) 38%, transparent); | |
| 1043 | +} | |
| 1044 | + | |
| 1045 | +.coverage-clear { | |
| 1046 | + border-color: color-mix(in srgb, var(--moss) 32%, var(--line)); | |
| 1047 | + background: color-mix(in srgb, var(--moss) 6%, var(--card)); | |
| 959 | 1048 | } |
| 960 | 1049 | |
| 961 | 1050 | .coverage-clear .coverage-dot { |
| 962 | 1051 | border-color: var(--moss); |
| 963 | 1052 | background: var(--moss); |
| 964 | - transform: scale(1.1); | |
| 965 | 1053 | } |
| 966 | 1054 | |
| 967 | 1055 | /* ---------- Stage ---------- */ |
| @@ -990,10 +1078,11 @@ | ||
| 990 | 1078 | |
| 991 | 1079 | .status-caption { |
| 992 | 1080 | font-size: 13px; |
| 993 | - letter-spacing: 0.08em; | |
| 1081 | + letter-spacing: 0.01em; | |
| 994 | 1082 | color: var(--ink-soft); |
| 995 | 1083 | margin: 4px 0 18px; |
| 996 | 1084 | min-height: 20px; |
| 1085 | + max-width: 420px; | |
| 997 | 1086 | } |
| 998 | 1087 | |
| 999 | 1088 | /* ---------- Orb ---------- */ |
| @@ -1163,20 +1252,23 @@ | ||
| 1163 | 1252 | } |
| 1164 | 1253 | |
| 1165 | 1254 | .mic-button { |
| 1166 | - width: 72px; | |
| 1167 | - height: 72px; | |
| 1168 | - border-radius: 50%; | |
| 1255 | + display: inline-flex; | |
| 1256 | + align-items: center; | |
| 1257 | + justify-content: center; | |
| 1258 | + gap: 10px; | |
| 1259 | + min-width: 168px; | |
| 1260 | + min-height: 52px; | |
| 1261 | + padding: 0 20px; | |
| 1169 | 1262 | border: 1.5px solid var(--line); |
| 1263 | + border-radius: 14px; | |
| 1170 | 1264 | background: var(--card); |
| 1171 | 1265 | color: var(--ink); |
| 1172 | 1266 | cursor: pointer; |
| 1173 | - display: flex; | |
| 1174 | - align-items: center; | |
| 1175 | - justify-content: center; | |
| 1267 | + font-size: 14px; | |
| 1268 | + font-weight: 700; | |
| 1176 | 1269 | transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, |
| 1177 | 1270 | transform 0.2s ease, box-shadow 0.3s ease; |
| 1178 | 1271 | box-shadow: 0 10px 24px -14px rgba(23, 25, 45, 0.4); |
| 1179 | - touch-action: none; | |
| 1180 | 1272 | } |
| 1181 | 1273 | |
| 1182 | 1274 | .mic-button:hover:not(:disabled) { |
| @@ -1186,11 +1278,11 @@ | ||
| 1186 | 1278 | } |
| 1187 | 1279 | |
| 1188 | 1280 | .mic-button.recording { |
| 1189 | - background: var(--clay); | |
| 1190 | - border-color: var(--clay); | |
| 1281 | + background: var(--pulse); | |
| 1282 | + border-color: var(--pulse); | |
| 1191 | 1283 | color: var(--card); |
| 1192 | - transform: scale(1.08); | |
| 1193 | - box-shadow: 0 0 0 10px rgba(72, 86, 232, 0.14), 0 14px 30px -14px rgba(53, 65, 199, 0.68); | |
| 1284 | + box-shadow: 0 0 0 8px color-mix(in srgb, var(--pulse) 13%, transparent), | |
| 1285 | + 0 14px 30px -14px color-mix(in srgb, var(--pulse) 72%, transparent); | |
| 1194 | 1286 | } |
| 1195 | 1287 | |
| 1196 | 1288 | .mic-button:disabled { |
| @@ -1200,8 +1292,9 @@ | ||
| 1200 | 1292 | |
| 1201 | 1293 | .mic-hint { |
| 1202 | 1294 | font-size: 12px; |
| 1203 | - letter-spacing: 0.06em; | |
| 1295 | + line-height: 1.45; | |
| 1204 | 1296 | color: var(--ink-soft); |
| 1297 | + max-width: 360px; | |
| 1205 | 1298 | } |
| 1206 | 1299 | |
| 1207 | 1300 | .answer-form { |
| @@ -1215,7 +1308,7 @@ | ||
| 1215 | 1308 | flex: 1; |
| 1216 | 1309 | padding: 11px 16px; |
| 1217 | 1310 | border: 1.5px solid var(--line); |
| 1218 | - border-radius: 999px; | |
| 1311 | + border-radius: 12px; | |
| 1219 | 1312 | background: var(--card); |
| 1220 | 1313 | color: var(--ink); |
| 1221 | 1314 | font-size: 15px; |
| @@ -1607,6 +1700,18 @@ | ||
| 1607 | 1700 | align-items: flex-start; |
| 1608 | 1701 | gap: 6px; |
| 1609 | 1702 | } |
| 1703 | + | |
| 1704 | + .coverage-panel { | |
| 1705 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 1706 | + } | |
| 1707 | + | |
| 1708 | + .answer-form { | |
| 1709 | + flex-wrap: wrap; | |
| 1710 | + } | |
| 1711 | + | |
| 1712 | + .answer-form .btn-primary { | |
| 1713 | + width: 100%; | |
| 1714 | + } | |
| 1610 | 1715 | } |
| 1611 | 1716 | |
| 1612 | 1717 | @media (prefers-reduced-motion: reduce) { |
modified client/src/App.tsx +15 −9
| @@ -28,10 +28,10 @@function statusLabel(status: Session['status']): string { | ||
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | const CAPTIONS: Record<VoiceState, string> = { |
| 31 | - idle: 'hold the mic or press space to answer', | |
| 32 | - listening: 'listening…', | |
| 33 | - thinking: 'thinking…', | |
| 34 | - speaking: 'speaking…', | |
| 31 | + idle: 'Ready for your answer.', | |
| 32 | + listening: 'Listening. Send the recording when you are finished.', | |
| 33 | + thinking: 'Turning your answer into the next question.', | |
| 34 | + speaking: 'Reading the question aloud. Press Escape to stop.', | |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { |
| @@ -388,7 +388,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 388 | 388 | onChange={(e) => setTtsEnabled(e.target.checked)} |
| 389 | 389 | /> |
| 390 | 390 | <span className="tts-track" aria-hidden="true" /> |
| 391 | - voice | |
| 391 | + read aloud | |
| 392 | 392 | </label> |
| 393 | 393 | </div> |
| 394 | 394 | </header> |
| @@ -402,7 +402,9 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 402 | 402 | <> |
| 403 | 403 | <QuestionCard question={latestQuestion(session)} /> |
| 404 | 404 | <Waveform mode={waveformMode} levelRef={levelRef} /> |
| 405 | - <p className="status-caption">{CAPTIONS[voiceState]}</p> | |
| 405 | + <p className="status-caption" role="status" aria-live="polite"> | |
| 406 | + {CAPTIONS[voiceState]} | |
| 407 | + </p> | |
| 406 | 408 | |
| 407 | 409 | <PushToTalkButton |
| 408 | 410 | disabled={submitting} |
| @@ -415,11 +417,11 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 415 | 417 | <input |
| 416 | 418 | value={answerText} |
| 417 | 419 | onChange={(e) => setAnswerText(e.target.value)} |
| 418 | - placeholder="or type your answer…" | |
| 420 | + placeholder="Type your answer instead" | |
| 419 | 421 | disabled={submitting} |
| 420 | 422 | /> |
| 421 | 423 | <button type="submit" className="btn-primary" disabled={submitting || !answerText.trim()}> |
| 422 | - Send | |
| 424 | + Send answer | |
| 423 | 425 | </button> |
| 424 | 426 | </form> |
| 425 | 427 | <button type="button" className="done-button" disabled={submitting} onClick={() => submit('done')}> |
| @@ -436,7 +438,11 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 436 | 438 | </> |
| 437 | 439 | )} |
| 438 | 440 | |
| 439 | - {error && <p className="error">{error}</p>} | |
| 441 | + {error && ( | |
| 442 | + <p className="error" role="alert"> | |
| 443 | + {error} | |
| 444 | + </p> | |
| 445 | + )} | |
| 440 | 446 | </main> |
| 441 | 447 | |
| 442 | 448 | <Transcript segments={session.segments} /> |
modified client/src/components/CoveragePanel.tsx +40 −7
| @@ -1,30 +1,63 @@ | ||
| 1 | -import { CATEGORY_IDS, type Coverage } from 'shared/types' | |
| 1 | +import { CATEGORY_IDS, type Coverage, type CoverageLevel } from 'shared/types' | |
| 2 | 2 | import { CATEGORY_LABELS } from '../labels' |
| 3 | 3 | |
| 4 | 4 | interface CoveragePanelProps { |
| 5 | 5 | coverage: Coverage |
| 6 | 6 | } |
| 7 | 7 | |
| 8 | +const STATUS_LABELS: Record<CoverageLevel, string> = { | |
| 9 | + missing: 'To discuss', | |
| 10 | + partial: 'In progress', | |
| 11 | + clear: 'Ready', | |
| 12 | +} | |
| 13 | + | |
| 8 | 14 | export function CoveragePanel({ coverage }: CoveragePanelProps) { |
| 9 | - const clearedCount = CATEGORY_IDS.filter((category) => coverage[category] === 'clear').length | |
| 15 | + const readyCount = CATEGORY_IDS.filter((category) => coverage[category] === 'clear').length | |
| 16 | + const activeCount = CATEGORY_IDS.filter((category) => coverage[category] === 'partial').length | |
| 17 | + const remainingCount = CATEGORY_IDS.length - readyCount - activeCount | |
| 18 | + const progress = (readyCount / CATEGORY_IDS.length) * 100 | |
| 10 | 19 | |
| 11 | 20 | return ( |
| 12 | - <div> | |
| 21 | + <section className="coverage-card" aria-labelledby="coverage-title"> | |
| 22 | + <div className="coverage-heading"> | |
| 23 | + <div> | |
| 24 | + <p className="coverage-kicker">Your build brief</p> | |
| 25 | + <h2 id="coverage-title"> | |
| 26 | + {readyCount} of {CATEGORY_IDS.length} topics ready | |
| 27 | + </h2> | |
| 28 | + </div> | |
| 29 | + <span className="coverage-fraction" aria-hidden="true"> | |
| 30 | + {readyCount}/{CATEGORY_IDS.length} | |
| 31 | + </span> | |
| 32 | + </div> | |
| 33 | + <div | |
| 34 | + className="coverage-track" | |
| 35 | + role="progressbar" | |
| 36 | + aria-label="Build brief topics ready" | |
| 37 | + aria-valuemin={0} | |
| 38 | + aria-valuemax={CATEGORY_IDS.length} | |
| 39 | + aria-valuenow={readyCount} | |
| 40 | + > | |
| 41 | + <span style={{ width: `${progress}%` }} /> | |
| 42 | + </div> | |
| 13 | 43 | <p className="coverage-progress"> |
| 14 | - {clearedCount} of {CATEGORY_IDS.length} done | |
| 44 | + {readyCount} ready, {activeCount} in progress, {remainingCount} to discuss | |
| 15 | 45 | </p> |
| 16 | 46 | <ul className="coverage-panel"> |
| 17 | 47 | {CATEGORY_IDS.map((category) => ( |
| 18 | 48 | <li |
| 19 | 49 | key={category} |
| 20 | 50 | className={`coverage-item coverage-${coverage[category]}`} |
| 21 | - title={`${CATEGORY_LABELS[category].description} (${coverage[category]})`} | |
| 51 | + aria-label={`${CATEGORY_LABELS[category].label}: ${STATUS_LABELS[coverage[category]]}. ${CATEGORY_LABELS[category].description}`} | |
| 22 | 52 | > |
| 23 | 53 | <span className="coverage-dot" aria-hidden="true" /> |
| 24 | - {CATEGORY_LABELS[category].label} | |
| 54 | + <span className="coverage-item-copy"> | |
| 55 | + <span>{CATEGORY_LABELS[category].label}</span> | |
| 56 | + <span className="coverage-status">{STATUS_LABELS[coverage[category]]}</span> | |
| 57 | + </span> | |
| 25 | 58 | </li> |
| 26 | 59 | ))} |
| 27 | 60 | </ul> |
| 28 | - </div> | |
| 61 | + </section> | |
| 29 | 62 | ) |
| 30 | 63 | } |
modified client/src/components/PushToTalkButton.tsx +109 −42
| @@ -8,52 +8,107 @@interface PushToTalkButtonProps { | ||
| 8 | 8 | onLevel?: (level: number) => void |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | -function isTypingTarget(target: EventTarget | null): boolean { | |
| 11 | +function isInteractiveTarget(target: EventTarget | null): boolean { | |
| 12 | 12 | if (!(target instanceof HTMLElement)) return false |
| 13 | - return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable | |
| 13 | + return ( | |
| 14 | + ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName) || | |
| 15 | + target.isContentEditable | |
| 16 | + ) | |
| 14 | 17 | } |
| 15 | 18 | |
| 16 | 19 | const MAX_RECORDING_SECONDS = 120 |
| 17 | 20 | |
| 18 | 21 | 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 | + const minutes = Math.floor(seconds / 60) | |
| 23 | + const remainingSeconds = seconds % 60 | |
| 24 | + return `${minutes}:${String(remainingSeconds).padStart(2, '0')}` | |
| 22 | 25 | } |
| 23 | 26 | |
| 24 | -export function PushToTalkButton({ disabled, onRecorded, onRecordingChange, onLevel }: PushToTalkButtonProps) { | |
| 27 | +export function PushToTalkButton({ | |
| 28 | + disabled, | |
| 29 | + onRecorded, | |
| 30 | + onRecordingChange, | |
| 31 | + onLevel, | |
| 32 | +}: PushToTalkButtonProps) { | |
| 25 | 33 | const [recording, setRecording] = useState(false) |
| 34 | + const [sending, setSending] = useState(false) | |
| 26 | 35 | const [elapsed, setElapsed] = useState(0) |
| 27 | 36 | const [error, setError] = useState<string | null>(null) |
| 28 | 37 | const recorderRef = useRef(createPushToTalkRecorder()) |
| 29 | 38 | const activeRef = useRef(false) |
| 39 | + const startingRef = useRef(false) | |
| 40 | + const stopRequestedRef = useRef(false) | |
| 41 | + const sendingRef = useRef(false) | |
| 42 | + const parentSubmissionSeenRef = useRef(false) | |
| 43 | + | |
| 44 | + const finishRecording = useCallback(async () => { | |
| 45 | + if (!activeRef.current || sendingRef.current) return | |
| 46 | + activeRef.current = false | |
| 47 | + sendingRef.current = true | |
| 48 | + setSending(true) | |
| 49 | + setRecording(false) | |
| 50 | + onRecordingChange?.(false) | |
| 51 | + try { | |
| 52 | + const blob = await recorderRef.current.stop() | |
| 53 | + onRecorded(blob) | |
| 54 | + } catch { | |
| 55 | + sendingRef.current = false | |
| 56 | + setSending(false) | |
| 57 | + setError('Could not send the recording. Try again, or type your answer below.') | |
| 58 | + } | |
| 59 | + }, [onRecorded, onRecordingChange]) | |
| 30 | 60 | |
| 31 | 61 | const startRecording = useCallback(async () => { |
| 32 | - if (disabled || activeRef.current) return | |
| 33 | - activeRef.current = true | |
| 62 | + if (disabled || sendingRef.current || activeRef.current || startingRef.current) return | |
| 63 | + startingRef.current = true | |
| 64 | + stopRequestedRef.current = false | |
| 34 | 65 | setError(null) |
| 35 | 66 | try { |
| 36 | 67 | await recorderRef.current.start(onLevel) |
| 68 | + startingRef.current = false | |
| 69 | + activeRef.current = true | |
| 37 | 70 | setRecording(true) |
| 38 | 71 | onRecordingChange?.(true) |
| 39 | - } catch (err) { | |
| 72 | + if (stopRequestedRef.current) { | |
| 73 | + stopRequestedRef.current = false | |
| 74 | + await finishRecording() | |
| 75 | + } | |
| 76 | + } catch { | |
| 77 | + startingRef.current = false | |
| 40 | 78 | activeRef.current = false |
| 41 | - setError(err instanceof Error ? err.message : 'could not access the microphone') | |
| 79 | + setError('Could not access your microphone. Check browser permission, or type your answer below.') | |
| 42 | 80 | } |
| 43 | - }, [disabled, onLevel, onRecordingChange]) | |
| 81 | + }, [disabled, finishRecording, onLevel, onRecordingChange]) | |
| 44 | 82 | |
| 45 | 83 | const stopRecording = useCallback(async () => { |
| 46 | - if (!activeRef.current) return | |
| 47 | - activeRef.current = false | |
| 48 | - setRecording(false) | |
| 49 | - onRecordingChange?.(false) | |
| 50 | - try { | |
| 51 | - const blob = await recorderRef.current.stop() | |
| 52 | - onRecorded(blob) | |
| 53 | - } catch (err) { | |
| 54 | - setError(err instanceof Error ? err.message : 'recording failed') | |
| 84 | + if (startingRef.current) { | |
| 85 | + stopRequestedRef.current = true | |
| 86 | + return | |
| 55 | 87 | } |
| 56 | - }, [onRecorded, onRecordingChange]) | |
| 88 | + await finishRecording() | |
| 89 | + }, [finishRecording]) | |
| 90 | + | |
| 91 | + const toggleRecording = useCallback(async () => { | |
| 92 | + if (disabled || sendingRef.current) return | |
| 93 | + if (activeRef.current || startingRef.current) { | |
| 94 | + await stopRecording() | |
| 95 | + return | |
| 96 | + } | |
| 97 | + await startRecording() | |
| 98 | + }, [disabled, startRecording, stopRecording]) | |
| 99 | + | |
| 100 | + useEffect(() => { | |
| 101 | + if (!sendingRef.current) return | |
| 102 | + if (disabled) { | |
| 103 | + parentSubmissionSeenRef.current = true | |
| 104 | + return | |
| 105 | + } | |
| 106 | + if (parentSubmissionSeenRef.current) { | |
| 107 | + parentSubmissionSeenRef.current = false | |
| 108 | + sendingRef.current = false | |
| 109 | + setSending(false) | |
| 110 | + } | |
| 111 | + }, [disabled]) | |
| 57 | 112 | |
| 58 | 113 | useEffect(() => { |
| 59 | 114 | if (!recording) { |
| @@ -71,13 +126,13 @@export function PushToTalkButton({ disabled, onRecorded, onRecordingChange, onLe | ||
| 71 | 126 | |
| 72 | 127 | useEffect(() => { |
| 73 | 128 | function handleKeyDown(event: KeyboardEvent) { |
| 74 | - if (event.code !== 'Space' || event.repeat || isTypingTarget(event.target)) return | |
| 129 | + if (event.code !== 'Space' || event.repeat || isInteractiveTarget(event.target)) return | |
| 75 | 130 | event.preventDefault() |
| 76 | 131 | void startRecording() |
| 77 | 132 | } |
| 78 | 133 | |
| 79 | 134 | function handleKeyUp(event: KeyboardEvent) { |
| 80 | - if (event.code !== 'Space' || isTypingTarget(event.target)) return | |
| 135 | + if (event.code !== 'Space' || isInteractiveTarget(event.target)) return | |
| 81 | 136 | event.preventDefault() |
| 82 | 137 | void stopRecording() |
| 83 | 138 | } |
| @@ -90,37 +145,49 @@export function PushToTalkButton({ disabled, onRecorded, onRecordingChange, onLe | ||
| 90 | 145 | } |
| 91 | 146 | }, [startRecording, stopRecording]) |
| 92 | 147 | |
| 148 | + const unavailable = disabled || sending | |
| 149 | + const buttonLabel = unavailable ? 'Sending...' : recording ? 'Send recording' : 'Start talking' | |
| 150 | + const hint = unavailable | |
| 151 | + ? 'Turning your recording into text.' | |
| 152 | + : recording | |
| 153 | + ? `${formatElapsed(elapsed)} recording. Click again to send.` | |
| 154 | + : 'Click once to start, or hold Space while you talk.' | |
| 155 | + | |
| 93 | 156 | return ( |
| 94 | 157 | <div className="push-to-talk"> |
| 95 | 158 | <button |
| 96 | 159 | type="button" |
| 97 | 160 | className={`mic-button ${recording ? 'recording' : ''}`} |
| 98 | - disabled={disabled} | |
| 99 | - aria-label={recording ? 'Release to send' : 'Hold to talk'} | |
| 100 | - onMouseDown={() => void startRecording()} | |
| 101 | - onMouseUp={() => void stopRecording()} | |
| 102 | - onMouseLeave={() => { | |
| 103 | - if (recording) void stopRecording() | |
| 104 | - }} | |
| 105 | - onTouchStart={(e) => { | |
| 106 | - e.preventDefault() | |
| 107 | - void startRecording() | |
| 108 | - }} | |
| 109 | - onTouchEnd={(e) => { | |
| 110 | - e.preventDefault() | |
| 111 | - void stopRecording() | |
| 112 | - }} | |
| 161 | + disabled={unavailable} | |
| 162 | + aria-label={buttonLabel} | |
| 163 | + aria-pressed={recording} | |
| 164 | + aria-describedby="microphone-hint" | |
| 165 | + onClick={() => void toggleRecording()} | |
| 113 | 166 | > |
| 114 | - <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"> | |
| 167 | + <svg | |
| 168 | + viewBox="0 0 24 24" | |
| 169 | + width="22" | |
| 170 | + height="22" | |
| 171 | + fill="none" | |
| 172 | + stroke="currentColor" | |
| 173 | + strokeWidth="1.8" | |
| 174 | + strokeLinecap="round" | |
| 175 | + aria-hidden="true" | |
| 176 | + > | |
| 115 | 177 | <rect x="9" y="3" width="6" height="11" rx="3" /> |
| 116 | 178 | <path d="M5 11a7 7 0 0 0 14 0" /> |
| 117 | 179 | <path d="M12 18v3" /> |
| 118 | 180 | </svg> |
| 181 | + <span>{buttonLabel}</span> | |
| 119 | 182 | </button> |
| 120 | - <p className="mic-hint"> | |
| 121 | - {recording ? `${formatElapsed(elapsed)} · release to send` : 'hold to talk · or press space'} | |
| 183 | + <p className="mic-hint" id="microphone-hint" role="status" aria-live="polite"> | |
| 184 | + {hint} | |
| 122 | 185 | </p> |
| 123 | - {error && <p className="error">{error}</p>} | |
| 186 | + {error && ( | |
| 187 | + <p className="error" role="alert"> | |
| 188 | + {error} | |
| 189 | + </p> | |
| 190 | + )} | |
| 124 | 191 | </div> |
| 125 | 192 | ) |
| 126 | 193 | } |
modified client/src/components/Transcript.tsx +1 −1
| @@ -21,7 +21,7 @@export function Transcript({ segments }: TranscriptProps) { | ||
| 21 | 21 | <div ref={listRef} className="transcript"> |
| 22 | 22 | {segments.map((segment) => ( |
| 23 | 23 | <div key={segment.id} className={`segment segment-${segment.speaker}`}> |
| 24 | - <span className="segment-speaker">{segment.speaker === 'user' ? 'You' : 'Interviewer'}</span> | |
| 24 | + <span className="segment-speaker">{segment.speaker === 'user' ? 'You' : 'VoiceTask'}</span> | |
| 25 | 25 | <p className="segment-text">{segment.text}</p> |
| 26 | 26 | </div> |
| 27 | 27 | ))} |
modified spec/TASKS.md +1 −1
| @@ -87,7 +87,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 87 | 87 | - Depends: T16 |
| 88 | 88 | - Verify: `npm run typecheck` and `npm run build` exit 0. Static audit: every first-run claim maps to an existing requirement and all interactive controls have visible keyboard focus. |
| 89 | 89 | |
| 90 | -- [ ] T18 Easier interview controls and progress | |
| 90 | +- [x] T18 Easier interview controls and progress | |
| 91 | 91 | - Change the microphone to click once to record and click again to send, while keeping Space as hold-to-talk outside controls. Add explicit idle, recording, sending, and error copy. Replace the coverage count and color-only dots with plain progress language and per-category status labels. |
| 92 | 92 | - Depends: T17 |
| 93 | 93 | - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: mouse click toggles one recording, Space still records only while held, and coverage statuses are understandable without color. |