Commit
Merge origin/main: reconcile two independently-built implementations
commit
22a1c3b
26 changed files with +1541 and −460
Jump to a changed file
- .env.example +18 −0
- .github/workflows/ci.yml +22 −0
- .gitignore +1 −0
- README.md +50 −0
- client/index.html +10 −0
- client/src/App.css +820 −279
- client/src/App.tsx +180 −90
- client/src/api.ts +8 −0
- client/src/audio.ts +29 −2
- client/src/components/CoveragePanel.tsx +1 −1
- client/src/components/PushToTalkButton.tsx +49 −6
- client/src/components/QuestionCard.tsx +2 −5
- client/src/components/Transcript.tsx +20 −11
- client/src/components/VoiceOrb.tsx +57 −0
- client/src/components/Waveform.tsx +64 −0
- client/src/index.css +48 −30
- client/src/tts.ts +33 −2
- package-lock.json +35 −9
- package.json +7 −3
- server/index.ts +3 −0
- server/providers/llmAnthropic.ts +24 −15
- server/routes/sessions.test.ts +18 −1
- server/routes/sessions.ts +6 −0
- server/store/sessionStore.test.ts +13 −3
- server/store/sessionStore.ts +17 −2
- 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 | + × | |
| 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 | + ← Sessions | |
| 254 | + </button> | |
| 255 | + </header> | |
| 256 | + {error ? <p className="error">{error}</p> : <p className="muted">Loading…</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 | + ← 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({ |