ALL-127 · Evaluation
The feature is worth building and the hard part is not the one the issue flags. Screenshot capture is already solved by the fact that we own the renderer — it needs no new flag and costs nothing in steady state. The risk was transcription, and the car has now settled it: browser speech recognition is present in the in-car Chromium and does not work there, failing seconds after the driver has finished speaking. Design for that — the prototype does — and the rest is plumbing.
Recommendation. Ship in three phases. Phase 1 (context + screenshot + typed text) is a two-day change that already makes reports triageable. Phase 2 adds the voice race. Phase 3 moves the sink off email into Rally Control. Do not block Phase 1 on the voice work.
Measured in the car
A 15" Model 3/Y, MCU3, 2026 summer firmware (Chromium 148), 2026-08-29. Every number below came off this page in that car.
| Probe | Result |
|---|---|
| Microphone | Permission granted, getUserMedia works, recorder mime audio/webm;codecs=opus |
| Speech recognition | API present · live trial returned no result and error: network. The session fell through to upload and kept 21.7 KB for 6 s (~29 kbps). |
| On-device recognition | API present (available / install / processLocally), status downloadable — but no pack ever lands. en pending 75+ min, en-US the same. chrome://components is blocked (ERR_INVALID_URL). Unavailable in practice. |
| Drawing buffer | Same-frame capture has pixels in 0.3 ms — and a read deferred by two composited frames still has them. This firmware keeps the buffer after compositing. |
| Capture, native panel | Buffer is 1183×840. JPEG q0.7 = 35.6 KB in 22 ms; q0.85 50.3 KB/37 ms; WebP 15.4 KB/95 ms; PNG 120.5 KB/20 ms |
| preserveDrawingBuffer | 60.0 → 59.5 fps at 960×600 (−0.8%) |
foreignObject raster | Blank, same as desktop |
| Detection | Tesla yes (gpu+touch), mcu3, ANGLE AMD Radeon Vega 1 (radeonsi raven ACO) |
| WebGL2 | yes — see side findings |
| Screen | 1254 css · 1919 physical · dpr 1.53 · screenX 1254 |
| Connection | 4g · 10 Mbps · 150 ms RTT · 2 GB / 8 cores |
The one that decided the design. Speech recognition is present in the car and does not work in the car. Had the dialog trusted the API and started only recognition, the driver would have spoken a full sentence into a microphone that was recording nothing, and been told network error afterwards. The race caught it: 21.7 KB of audio was already on disk when the error arrived, and the driver saw the path badge flip from race to upload without losing a word. This is not a fallback worth having — it is the primary path in every car we have measured.
1 · Screenshot — settled
Never turn on preserveDrawingBuffer
The flag exists because a WebGL drawing buffer is cleared when the frame is composited. But compositing cannot happen in the middle of a task — so a render immediately followed by a read, in the same task, always sees pixels. Peregrine owns its render loop, so the capture is four lines:
// src/peregrine/facade/map.js — beside the _tick render call
captureFrame(maxWidth) {
this.renderer.render(this.scene, this._cc.camera); // what _tick calls
const [w, h] = fit(this._canvas, maxWidth); // AFTER the render:
out.width = w; out.height = h; // an unlaid-out canvas
out.getContext('2d').drawImage(this._canvas, 0, 0, w, h);
return new Promise((r) => out.toBlob(r, 'image/jpeg', 0.7));
}
Sizing the output from the drawing buffer after the render is not a detail: a canvas that has not been laid out yet reports the 300×150 default, and a capture taken in that window ships a thumbnail and no error. This prototype shipped that bug for an hour.
Cost: one extra frame, once, when the driver taps send.
Measured in the car, the flag is not expensive: 60.0 → 59.5 fps, −0.8%, at 960×600 on MCU3. So the case against it is not a frame-rate cliff — it is that same-frame capture is free, needs no flag, and cannot regress. The flag is a permanent per-frame tax on every drive for a feature used a handful of times per car per year, and that measurement is from the strong GPU at less than half the panel's pixels; MCU2 at 1919×1130 is a different bet. Take the free option.
Composite the chrome by painting it, not by rasterising it
DOM rasterisation (html2canvas, or the foreignObject trick underneath it) needs every stylesheet inlined, cannot load the webfont, may taint the canvas, and is a 200 KB dependency in an app whose whole public/ is deliberately unbundled. We own every pixel of the HUD, so the cheap answer is to draw a schematic of it onto the captured frame with ~80 lines of Canvas2D — which is what this prototype does, and what the screenshots it produces show. The exact HUD values ride in the JSON anyway; the picture only has to say what the driver was looking at.
Desktop Chromium behaves exactly as the same-task rule predicts: render + read is 1.5 ms, and a read deferred by two animation frames comes back blank.
The car does not. With the map genuinely on screen — the first attempt measured it hidden, where nothing composites and therefore nothing is ever cleared — a read deferred by two composited frames still holds pixels. This firmware keeps the drawing buffer. Read + render is 0.3 ms.
That is margin, not a licence. Same-frame capture stays the design: it is correct on every browser, it is what desktop requires, and a firmware update could align the car with the spec at any time without telling us. What the finding buys is robustness — a capture that arrives a frame or two late still produces an image in the car rather than a black rectangle, which makes the failure mode of a slow tap or a scheduling hiccup benign instead of silent.
Size and format
From the Lab matrix on this scene:
| Frame | Bytes | Encode |
|---|---|---|
| 3456×1738 JPEG q0.85 (native) | 163 KB | 85 ms |
| 1600×805 JPEG q0.7 | 43 KB | 28 ms |
| 1280×644 JPEG q0.7 | 32 KB | 12 ms |
| 1280×644 WebP q0.7 | 15 KB | 48 ms |
| 1280×644 PNG | 147 KB | 13 ms |
Take 1280 px wide, JPEG q0.7. WebP halves the bytes and quadruples the encode — a trade worth re-measuring on MCU2, where 48 ms of desktop encode could be 200 ms, but not worth taking on faith. PNG is off the table. Note this synthetic city is far flatter than a real basemap with labels and textures; budget 2–4× these numbers, so ~100 KB, and downscale before encoding rather than after.
Send the image as a Blob from canvas.toBlob() in a multipart upload, not as base64 inside the JSON. Base64 adds 37% to something already going over LTE, and toDataURL is synchronous on the main thread — on a car mid-navigation that is a visible stutter, for no reason.
2 · Voice — the actual risk
Microphone access works on 2026 firmware. Recognition does not — and it fails in the worst possible way. webkitSpeechRecognition is present in the car's Chromium, accepts start() without complaint, and then throws error: network seconds after the driver has finished a sentence, because recognition is a Google service the in-car build has no keys for. Confirmed in the car, not inferred. Losing what someone said at 100 km/h is the one failure this feature cannot have.
Race both paths. Start MediaRecorder and SpeechRecognition in the same tick. The first live result wins and the recording is discarded — no upload, no transcription bill, nothing stored. If recognition is silent for 3.5 s or errors, the recording is already running and nothing is lost; it uploads for server transcription instead. The driver sees one microphone button and never learns which path ran.
| Path | Cost | Latency | Risk |
|---|---|---|---|
| Browser recognition | Zero | Live | Measured broken in-car (error: network); keep it armed for the day the firmware ships keys |
| Upload + server STT | ~$0.006/min (Whisper-class) | Seconds, after send | Upload size; a storage and retention obligation |
| Typed | Zero | — | Unusable while driving; the fallback, never the default |
So budget for the server path as the normal one, not the exception. Its cost is still not a constraint: 1,000 reports a month averaging 30 s is 500 minutes, about $3. The car recorded at ~29 kbps, so 30 s is ~110 KB — a second of transfer on the 10 Mbps LTE the car reported. Cap the recording at 90 s and both numbers stay bounded.
Can we get a real transcript in the car?
Yes — three ways, and the browser API as we called it is not one of them. Ranked by what they cost the driver:
- On-device recognition — ruled out in this car. The API is all there and honest about it:
available()reportsdownloadable. But no pack has ever landed.ensat pending for over an hour,en-USbehaved identically, andchrome://componentsis blocked so there is nothing further to read. The likely cause is the one already established: packs come from the same Google service infrastructure that in-car recognition cannot reach, anderror: networkplus a download that never completes are plausibly one blocked endpoint, not two problems. Keep the probe — a future firmware could ship a pack preinstalled, and then this path costs nothing and beats everything. Do not build on it. - Always ask for a full locale. The car reports
navigator.languageas bareen, and packs are per-locale — there is noen. Passing the device's tag straight through would have been a silent bug in production regardless of whether a pack exists. Resolve toen-US-shaped tags before any speech call. - On-device recognition, if it is ever already there. Chromium grew a local path (SODA) after the server-backed one: the same interface plus
processLocally, no network round trip — exactly what fails in the car. Confirmed present on desktop Chrome (available({ langs, processLocally })→downloadable); unknown on the car's build, so the Lab probes it. The product takes this path only when the status isavailable.downloadablecounts as unavailable: a hundred-megabyte model pull is not something a driver should discover by tapping a microphone, and the car is on metered LTE. Free if the pack is already installed, otherwise nothing happens. The Lab's download button is a diagnostic so a human can see what would happen — the dialog never calls it. - Transcribe the upload. Works today, no unknowns. Two shapes:
- On stop — one POST when the driver finishes, transcript back in a second or two, early enough to show before Send. Simplest thing that works.
- Near-live — text appearing as they speak. Needs a socket, and there is a trap: only the first
MediaRecorderchunk carries the WebM header, so later chunks are not independently decodable. Either re-send the growing blob every few seconds (simple, multiplies the per-minute bill by a small factor that is still pennies) or skipMediaRecorderentirely and push 16 kHz PCM from anAudioWorkletover a WebSocket. Do the second one only if live text turns out to matter.
- Whisper in WASM. Ruled out. The smallest useful model is ~75 MB over LTE and would compete with Peregrine for the CPU while the car is moving. Wrong trade in a driving app.
- Send the audio, transcribe later, show the driver nothing. The floor, and it is not a disaster: the report is complete, it is just not reviewable before sending. Keep it as the last resort it is — a driver who cannot see what was captured cannot correct a misheard sentence, and cannot decide not to send it.
And the driver may not need to see it at all. A transcript in the car is confirmation, not function — the report works if the audio arrives and the transcript is attached server-side. "Speak, tap stop, see 12 seconds captured, send" is a complete feature with zero transcription in the car. Treat live text as the nicety it is, and neither option above blocks shipping.
Never render a control that cannot work
The dialog probes on open — secure context, mediaDevices.getUserMedia, MediaRecorder, recognition — and picks its mode before it paints. Older firmware gets a text field with no microphone in sight, not a button that fails on tap.
3 · Context — where the value actually is
"The map went black" is not a bug report. "The map went black; MCU2; Peregrine; cityscape; three WebGL context-loss entries in the breadcrumbs; UI size 14 on a 2200 px panel" is a fix. Two fields do most of that work and neither is in the issue:
- Breadcrumbs — a capped ring buffer of the last 30 in-app actions (view changes, setting writes, route events). Free to keep, and it is what turns "it broke" into a repro.
- Captured errors — the last 10
window.onerror/ unhandled rejections, message and file only.
The rest is already written and shipping: public/vehicle.js does MCU and screen-class detection, and the marketing site's collectFingerprint() is the exact device dump the support form already sends. Reuse both rather than writing a third collector.
One honesty rule the prototype enforces: the review sheet renders the payload itself, not a description of it. If a field is too sensitive to show the driver, it is too sensitive to send.
4 · Privacy
The screenshot is the sensitive part, and not because of the map — because of the HUD, which carries the destination address and the ETA. That is a home address in most reports filed from a driveway.
- Coarse location by default — two decimals, ~1 km. Enough for "which region, which tile server", not enough to place someone's house. Exact is one tap away and opt-in.
- Screenshot is opt-out, visible before sending — the thumbnail in the dialog is the actual bytes that will be uploaded.
- Retention — 90 days for screenshots and audio, then hard delete; the transcript and the JSON can live as long as the issue does.
- Do not put binaries in an email. The current
/api/feedbackroute emails everything to a support inbox and caps the body at 96 KB. Screenshots and audio belong in object storage with a short-lived signed link, referenced from a row Rally Control can triage.
There is no transcription endpoint yet — the smallest one that works
Nothing in codriver speaks to a model today, and this feature does not justify standing up an inference service. The cheapest credible sink is a Cloudflare Worker in front of Workers AI: the account already has the binding, this page already runs on Pages, and Whisper-class models are available there per-request with a free daily allocation. One Function, no new vendor, no new bill to explain, and it keeps the audio off the driver app's origin entirely.
If live text later proves it matters, that same Worker becomes the WebSocket endpoint and the model behind it changes — a streaming provider instead of a batch one. The client contract does not move, which is the point of putting a Worker there rather than calling a vendor from the car.
5 · How it plugs into what exists
There is already a feedback path: codriver-marketing ships /support → POST /api/feedback → Resend, with categories, a client fingerprint and server-side rate limiting. Extend it; do not build a second one.
- Add
source: 'app',transcript,transcriptSource, and anattachmentsblock to the existing payload contract. - Raise the 96 KB body cap or, better, upload binaries separately and send only their keys.
- Add a
feedbacktable so reports are triageable in Rally Control instead of a mailbox. Email becomes a notification, not the system of record. - Keep the driver app thin: it collects and posts. No admin surface in the car — that rule is in CLAUDE.md for a reason.
6 · Where reports live, and the driver's own list
A driver who reports a bug should be able to see what happened to it. That means a status page in /account, and a status page needs a system of record — which is the decision to get right, because everything else hangs off it.
Own the table. A feedback row in the Postgres the auth tables already live in. Not a helpdesk's API, not a mailbox, not a chat channel. The moment a customer-facing page reads status from a third-party tool, that tool's uptime, rate limits and schema become codriver's — for a page that shows five rows.
Everything else is a view onto that table:
- Triage — Rally Control, which already queries this database with Drizzle and already has the operator's session. A list, a status dropdown and a reply box is a day of work, against weeks of operating someone else's Rails app.
- Notification — email or ntfy. A channel, never storage. "New report, 3 today" with a link into Rally Control.
- Engineering follow-through — a nullable
linear_issue_id. When a report becomes real work it gets an issue, and the driver's status page can honestly say fixed in 2026.9.2. - The inbox — Chatwoot, self-hosted (decided 2026-08-29, after briefly choosing Cloud). Cloud's pull was zero ops, but its paid tier buys mostly things this use case does not need, and its AI (Captain) is licensed separately and speaks the wrong API anyway. What changed the sum is where it runs: not the Coolify box, which already carries seventeen resources and spikes to full CPU, but a separate Oracle Cloud instance where the community edition is free and unconstrained. It consumes the
feedbacktable rather than replacing it — in-app reports are pushed in through its API so support lives in one place, and the conversation id rides back on the row.
The reply drafts, and why not Captain
Chatwoot's own AI, Captain, is bring-your-own-key — but it needs an Enterprise licence even self-hosted ($19/agent), and it speaks the OpenAI request shape, so pointing it at Claude means running a translating proxy. Self-hosting does not change that: the community edition simply does not include it. Webhooks and the API are in every edition and are all a drafting agent needs: message_created → a Worker → Claude with a cached context block → post back as a private note. Never an auto-send. A human edits and sends, and keeps doing so until the drafts have earned more than that.
The context block is small enough — docs, changelog, resolved threads — to live in the prompt with caching rather than in a vector store. At that size a draft costs a few cents, which is not a number worth engineering around.
Two things the /account tab forces a decision on
- Guests have no account to show a list in. The barrier stays low by design — anyone can report — but a report filed with no session can only be tied back by an email address the driver chose to leave. Ask for one optionally, after sending, framed as "want to hear back?" rather than as a gate before it.
- Status has to be honest and coarse. Four states the driver can read — received, looking at it, fixed, not planned — kept deliberately separate from whatever internal state Rally Control tracks. A status page that shows engineering's real queue depth is a promise nobody meant to make.
7 · Guests, abuse and rate limits
Anonymous audio upload is a cost and spam vector. It is still worth having — the reports we most need come from people who have not signed up — but bound it: 3 reports per hour per guest key (g:<guestId> → ip:<src>, the key lib/guest.js already builds), 90 s of audio, one screenshot, and no server transcription until a report passes a length sanity check. Capture only ever starts from an explicit tap.
8 · Placement
The app already has a button called Report and it means "report a police car ahead". A second thing called Report would be a mistake. This is Feedback, it lives in the top-right rail as an icon (and in Settings, which is where anyone parked will look for it), and it stays out of the bottom-right strip where the road-alert pill and the car's own "PASSENGER AIRBAG OFF" badge already compete.
9 · Side findings, unrelated to this issue
Two readings from the car contradict things written down elsewhere. Both matter more than this feature does.
webgl2: trueon MCU3. The recorded fact — from two 2026-05/06 captures — iswebgl2: falseon both MCU classes, and that is the stated reason Mapbox GL v3 cannot be adopted. The 2026 summer firmware (Chromium 148) reports WebGL2 available. Re-check MCU2 before anyone acts on it, but the constraint may simply be gone.screenXis reported honestly: 1254. ALL-43 shipped the Report-button placement control precisely because the app could not tell whether its pane was docked left or right —innerWidthis identical either way. It can: the pane knows where it sits on the desktop. Auto-placement is available whenever someone wants it.
10 · What still needs a real car
- Probe on-device recognition. The one result that could give the car a live transcript with no server. If it reports
available, run the local 6-second trial; ifdownloadable, only tap the download parked on Wi-Fi. - Re-run the readback and capture matrix. The first in-car run hit a prototype bug: switching to the Lab tab hides the map canvas, which reported zero size, which shrank the drawing buffer to 1×1 — so every capture came back as a 760-byte grey pixel that the page then called "blank". Fixed; the buffer now survives being hidden. Open "In the car" once, then run both.
- MCU2. Everything above is MCU3. The fps A/B and the JPEG encode time are the two numbers that could plausibly flip there.
- Is the microphone permission remembered across app loads? It read
grantedwithin a session; a prompt on every report is a feature nobody uses twice. - Does cabin audio (music, navigation voice) bleed into the recording, and does the car duck it while the mic is live?
11 · The plan
Phase 0 — close the last three unknowns
Half an hour in a car. None of it blocks Phase 1.
On-device recognition.Settled: unavailable. Two locales, neither landed;chrome://componentsblocked in the car. Server transcription it is.One clean Drawing buffer readback.Done: same-frame works in 0.3 ms, and this firmware keeps the buffer even after compositing — margin we did not expect and do not depend on.- An MCU2 pass — JPEG encode time and the fps A/B are the two numbers that could plausibly differ.
Phase 1 — the report, in the driver app
Self-contained. No server dependency, no Chatwoot, no transcription. Ship it first.
- Feedback in the top-right rail plus a Settings row — never named "Report", which already means police ahead.
- Capability probe on open; mic-first with both paths armed; text fallback that appears only when the mic cannot work.
- Same-frame capture at native resolution,
toBlobJPEG q0.7, HUD painted on. 36 KB. - Context: account, vehicle, settings, device, map state — plus breadcrumbs and captured errors, which are what make a report triageable.
- Coarse location by default; the review sheet renders the payload itself.
- Guests included, capped: 3 reports/hour on the existing guest key, 90 s of audio, one screenshot.
Phase 2 — the sink
feedbacktable in the shared Postgres; binaries to object storage, not into an email./api/feedbackextended withsource:'app', transcript and attachments; the 96 KB body cap raised or bypassed by a multipart upload.- A Worker in front of Workers AI transcribes the audio when it arrives. Batch on stop; streaming only if live text proves it matters.
- Chatwoot Cloud takes a copy as a conversation; its id rides back on the row.
Phase 3 — the loop
/account→ Your reports, served from our own table, four coarse states.- Rally Control triage: list, status, reply.
- Feature requests become Linear customer needs;
linear_issue_idcloses the loop back to the driver. - The drafting agent:
message_created→ Worker → Claude with a cached context block → private note. Never an auto-send.
Not doing, on purpose
Language-pack downloads from the driver's app · Whisper in WASM · Chatwoot's Captain · preserveDrawingBuffer · html2canvas · any admin surface in the car.
Prototype only. No data leaves this page — "Send" builds the exact payload and shows it to you instead of posting it. Map, HUD and drive are simulated; the WebGL context, the microphone, the recognition service and every measurement are real. In-car readings: 15" Model 3/Y, MCU3, Chromium 148, 2026-08-29.