profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

Simplify interview controls

commit 8d300df

6 changed files with +307 and −96

Jump to a changed file
  1. client/src/App.css +141 −36
  2. client/src/App.tsx +15 −9
  3. client/src/components/CoveragePanel.tsx +40 −7
  4. client/src/components/PushToTalkButton.tsx +109 −42
  5. client/src/components/Transcript.tsx +1 −1
  6. 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.