The home page capture box
How the fast-capture box on the home page behaves, and why.
A way out: cancel, and a Send button that unlocks (BL-453, 2026-09-10)
The owner's report, in his words. "There is nothing to cancel it. And it's stopping in the middle. Suppose at one point, user is changing mind and wanna cancel the whole process." And separately: "When I'm going back and forth between them, the program is going to stuck, a freeze, and it's messy."
Those are one piece of work, because the freeze is what made the missing cancel serious. The only way out of a stuck Send button was to reload the page — and reloading destroys a recording that has not been saved yet.
The freeze was the Send button, not the microphone
Recording and then pressing Send makes the page finish the recording first and re-send the form afterwards. While it finishes, every submit button in the form is disabled and relabelled "Please wait…". The re-sent form then took a different route through the code — the route that skips the step which re-enables the button. Nothing was left to unlock it, so the button sat disabled, wearing a spinner, until the page was reloaded.
Two details mattered for the fix. The lock overwrites the button's label as well as disabling it, so restoring only the disabled state would leave it reading "Please wait…" for ever; both halves are restored. And the unlock now happens on every way out of the submit handler, including the two that previously returned early.
Cancel, to the owner's design
The microphone button keeps one meaning — start and stop listening. Cancel is a separate ×, so a user who changes their mind does not have to reason about which press means what. It appears whenever there is something to lose: typed text, a recording, voice notes, attached files, or a save in flight.
- Nothing recorded — the box clears immediately. No dialog for an empty decision.
- A recording exists — a small confirm appears inside the box, his instruction being "a pop up letting user know that keep draft or cancel means delete it, then user make decisions." Keep as draft turns the recording into a draft Blink straight away, reviewable in Drafts. Delete recording removes it, and the wording says the deletion cannot be undone — because it cannot: the audio file itself is removed, so restoring the draft from the Recycle Bin would return a titled note with no sound in it. Never mind closes the confirm and changes nothing.
Cancel does not reload the page, deliberately. A reload would throw away the recording still held in the browser — the very thing the user might have just chosen to keep — and would leave them with an unexplained draft. The box is reset field by field instead, which also means it can say what happened.
The microphone is now released
The page used to take hold of the microphone and never let go until the tab closed. That is why the recording indicator could stay lit with nothing recording, and why a second microphone elsewhere on the page could fail to start. Cancelling now releases the device properly, and the next recording re-acquires it. Browsers remember the permission, so there is no second prompt.
For developers
The cancel control lives in the page's own markup and is hidden with
visibility rather than being created by script, so the tests that fake the box's state
by setting classes still see it. It is positioned inside the text field rather than beside
it: measured on a 375 px screen, adding it as a sibling left 117 px of typing room out of
a 280 px row — a quarter of the field gone — against 158 px when placed inside. A test
pins that floor at 150 px so a future control cannot quietly eat it.
Two faults here were found only by driving a real browser, and neither was visible to tests that read source code: cancelling a recording that had already stopped left a flag set, which then swallowed the next recording's chip; and saving straight after a cancel was being redirected to the plans page because the test's seeded plan was inactive. This is the case for the browser rig, in one paragraph.
Scope. This change adds a way out. It does not change which recording engine runs, or how switching between Catch a Blink and Talk to OrbitPilot picks one — that is the next piece of work, and it is the one that carries behavioural risk.
One microphone, one owner (BL-454, 2026-09-10)
The owner's instruction. "You said we have three mics, which is totally wrong. We should have just one mic." And, when asked how far that should reach: "Some of them should be independent and separate. But their working model could be the same… do not forget our target is going toward microservices, so we cannot combine all items from different sections."
So this is one owner, two backends, and no shared file between sections — not one microphone object that every part of the platform depends on. The bug reporter's recorder, which he is happy with, is deliberately untouched.
What was actually wrong
The capture bar chose which recording method to start by looking at the Catch a Blink / Talk to OrbitPilot toggle at the moment the microphone button was pressed — and switching that toggle stopped nothing at all.
So a recording started in one mode was abandoned rather than ended when the user switched to the other. It kept running, and because the page is built to treat a pause in speech as thinking rather than stopping, it kept restarting itself. It also kept hold of the microphone. That single fact explains three separate complaints that looked unrelated:
- "The mic is freezing and I cannot turn it off." The button now belonged to the other method, so pressing it started a second one instead of stopping the first.
- "It is not transcribing." The abandoned session held the microphone, so the recorder that was supposed to be capturing audio received silence.
- "The OrbitPilot mic cannot be turned on." Same cause, different door — the abandoned session was still holding the device when the assistant's own microphone asked for it.
What changed
- The button asks who is listening, not which mode is showing. If anything is listening, that is what stops — whichever method it is. Only when nothing is listening does the page choose a method, and it stays chosen for the whole of that recording.
- The toggle is a destination. Switching between Catch a Blink and Talk to
OrbitPilot now finishes the current recording first and then changes the page. What
was already captured is kept — finishing is not the same as cancelling, which is the
×. - The recorder announces its own state. The bar used to flip its own recording indicator on each press, which was correct only while every start and stop came from a click. It was wrong every time the recorder stopped by itself — a refused microphone permission, the automatic stop on a long recording, a recording with no sound in it, a failed upload, or switching away from the tab. The indicator now follows what the recorder reports.
- Sending a question to OrbitPilot releases the microphone first. Previously the page handed the question over while still holding the device, which is why the assistant's own microphone would then refuse to start.
- Voice notes are hidden while talking to OrbitPilot, and the "this was spoken" marker no longer survives a mode switch — which is why a typed note could previously be filed as a voice note.
Why there are still two recording methods
Not an oversight, and not a compromise. Signed-in people get the full recorder, which saves the audio itself so it can be transcribed later and re-transcribed if the first attempt was poor. People who are not signed in cannot have that — there is nowhere to put the audio and no account to attach it to — so for them the page uses the browser's own speech-to-text, which produces words and keeps no sound. Forcing one method would mean either giving anonymous visitors full audio recording or taking voice off the home page for everyone. Both were rejected. What there is exactly one of is the thing that decides who is listening.
For developers
The owner lives in static/js/capture-box.js as micOwner: an
engine bound once when a session starts and never re-read from
mode, plus a monotonic sessionId so a late asynchronous
continuation can tell it is stale. Sessions are released by identity — the shared reference is
nulled before stop() is called, so the dying session's own onend
fails its guard and cannot restart it. That guard appears in all four handlers and is counted by a
test; it is the same machine the OrbitPilot widget runs (BL-447).
Phase reporting is one of-voice-phase CustomEvent dispatched at the top of
setRecorderButtonMode() in templates/blink/_wink_voice_js.html — the top,
because every branch below it returns early. The Blink form ignores the event. Device coordination
across surfaces is a two-line event convention rather than a shared runtime module, deliberately:
a microphone object that errortracking, blink, messaging and
orbitpilot all import is the weld the owner ruled out and that
docs/architecture/FUTURE-PROOFING.md §1/§4b forbids independently.
When the microphone will not start, the page says why (BL-468, 2026-09-10)
What happened. The owner pressed the microphone on the home page in Chrome on his PC and got nothing at all — no permission prompt, no icon in the address bar, no message, and nothing in the browser console. Edge on the same machine recorded normally, and his microphone was fine. Chrome was being refused the device by Windows, at the per-app privacy level.
The part that was ours. Not the Windows setting — the silence. The page knew perfectly well that the microphone had been refused, and said nothing. A button that does nothing, for ever, with no explanation, is indistinguishable from a broken product, and it took about forty minutes of live investigation to establish that the code was not at fault.
It was silent twice, in two different places
This is why the report said "both Capture and Ask modes" — they are not the same machinery.
- Catch a Blink (signed in) uses the full recorder. It had messages written for exactly this situation — a refused permission, a browser that cannot record — but it wrote them into the status line belonging to the full Blink form. The home page does not draw that line, so the message was written into nothing.
- Talk to OrbitPilot, and every guest, use the browser's own speech-to-text. That path simply stopped the microphone when it failed and said nothing whatsoever.
What you will see now
The explanation appears in the hint line directly under the capture box — the place you are already looking, readable on a phone, and needing no hover. Four situations, four different sentences, because the useful half of a message is what to do next:
- The browser is blocking it. You are told to allow it from the icon in the address bar and to check Settings › Privacy & security › Microphone on Windows — because that is where the block actually was, and a message naming only the browser would have sent the owner round the same loop he was already stuck in.
- No microphone was found. Connect one and tap again. Being told to check permissions when you have no microphone wastes your time in the same way the silence did.
- Another app is using it. Close that app and tap again.
- This browser cannot record. Chrome, Edge or Safari will work.
A recording that is merely pausing still says nothing, on purpose. A thinking pause recovers by itself, and a warning about it would teach you to ignore the messages that matter.
For developers
The recorder in templates/blink/_wink_voice_js.html announces a stable
kind through an of-voice-device-problem CustomEvent — the same split
between announcing and painting that of-voice-phase already uses (BL-454). The recorder
does not choose the wording: static/js/capture-box.js owns
MIC_PROBLEM_MESSAGES, because the sentence that fits under the home page's capture box
is not the sentence that fits in the Blink form's status line. The Blink form does not listen, so
nothing there changed, and every existing setSubmitStatus call is untouched.
Web Speech's not-allowed, service-not-allowed and
audio-capture map onto the same four kinds, so a guest and a signed-in user are told
the same thing in the same words rather than drifting into two vocabularies for one failure.
One ordering trap is handled explicitly. The recorder partial is inline and runs
while the page is being parsed; capture-box.js is deferred and runs after
it. The unsupported-browser case is detected during the recorder's own start-up, so its event fires
before any listener exists. The kind is therefore recorded on
window.OF_VOICE_LAST_DEVICE_PROBLEM as well as dispatched, and the host page reads it
once on load. An event nobody is listening for yet is the same silence this change exists to end.
Why the tests had to be written differently. Every browser spec in this repo
grants the microphone and launches Chromium with a flag that makes the device always open — which is
exactly why this shipped: the one state nobody could test was the state the user was in.
config/test_mic_denied_browser.py takes the permission away instead, with shims that
reject the way a blocked device really does.
Telling the two × controls apart (BL-463, 2026-09-10)
What he saw. Within an hour of the cancel control reaching production, the owner filed a screenshot from his iPhone: "Shows 2 x sign one for voice and one for transcription. We should know after clicking on each what is happening." Two × glyphs sat one above the other, roughly 110 px apart on a 430 px-wide screen.
Both were correct, and that was the problem. They do genuinely different things with very different consequences:
- the box-level × discards the whole capture — text, files and recordings — and opens a keep-or-discard choice that says "cannot be undone";
- the chip × removed one voice note and left everything else alone.
Why a tooltip was never the fix
The box-level control explained itself through a title attribute. A
title renders as a hover tooltip, and a touchscreen has no hover. The
capture box is the phone-first surface of this product and the report came from a phone, so the one
explanation that existed could never appear for the person who needed it. The chip control had no
title at all, so it had none on any device.
Both carried a correct aria-label. That serves a screen reader and does nothing
whatsoever for a sighted touch user — and treating it as though it had discharged the duty to
explain a control is exactly the habit this change exists to break.
What you will see now
- The chip's control shows the word "Remove" beside its glyph. Symbols were what could not be told apart; a word is what fixes that, and it needs no press and no hover to read.
- It is deliberately subordinate — smaller than the chip's own text and in the chip's colour — so it reads as belonging to that recording rather than as a twin of the box-level control.
- No new confirmation. Removing one short recording is cheap and undone by recording again; the box-level control keeps its confirmation because it is the one that throws everything away.
- The glyph now comes from the icon registry like every other icon, instead of
being a typed-in character, so an admin who re-points "cancel" at
/adminapp/icons/changes both controls at once.
Why "Remove" and not "Delete"
The roomy Blink form labels its equivalent button "Delete". This one says "Remove" on purpose. The control's accessible name is already "Remove voice note N", and WCAG 2.5.3 (Label in Name) requires the visible label to be contained in the accessible name — someone using voice control speaks the words they can see. Labelling it "Delete" over an accessible name saying "Remove" would make the button unreachable by voice for the sake of matching another surface's wording. The lighter word also matches the lighter action: the form's Delete asks for confirmation, this does not.
For developers
The chip is built in JavaScript inside a Django template, so the registry markup is rendered once
into a constant (VOICE_CHIP_REMOVE_ICON) at the top of
templates/blink/_wink_voice_js.html and reused. The glyph is aria-hidden
because the button already carries the accessible name; announcing both would say it twice.
The capture bar must not move. Its idle-versus-recording stillness is pinned by
adminapp/test_capture_bar_visual.py, so the label lives inside the chip — which already
wraps in the same row-of-chips flex as the file chips — and nothing in the bar itself changed.
config/test_two_x_controls_browser.py asserts that directly, along with the absence of
horizontal overflow at 375 px with three chips present.
⚠ A test in that file was vacuous and the correction is worth knowing. Its first version compared the two controls' text and asserted they differed — which passed even with the bug fully restored, because the box-level control renders an icon element with no text at all, so "" ≠ "×" was true the entire time the report was being written. It was measuring a difference no human can see. It now asserts the chip's visible text contains actual letters, which is false for a bare glyph and true for a word.
⚠ And a trap in the test rig itself, found here. IconDefinition is
populated by a data migration, so a live-server test case truncates it between tests — and with that
table empty of_icon renders the empty string. Pages still load and
layout assertions still pass while every icon has silently vanished.
config/browser_testing.py gained a seed_icons() helper alongside
seed_plans(), which primes the icon cache rather than clearing it: clearing sends the
live-server thread to read that table exactly when the next truncation wants an exclusive lock on
it, which produced a real inter-test deadlock.
Search from the box, and your pinned Blinks (2026-09-26)
Search. Type what you are looking for in the box, then tap the magnifying glass under the send button. Nothing is saved and OrbitPilot is not asked - you land on your Blink Library with that search already applied. Pressing Enter still saves (or asks, in Ask mode); only the magnifying glass searches. It works in both modes.
- An empty box has nothing to search, so the hint under the box asks you to type first.
- If you are in the middle of a recording, or have picked files, save or cancel them first - they live only on this page and would be lost if you left it.
- Only when you are signed in (a visitor has no Library to search).
Pinned Blinks. When you have pinned at least one Blink, a small red pin appears under the box, just left of your Blink count. With one pinned Blink it opens that Blink directly; with several it opens your Library showing only the pinned ones. With none pinned, there is no pin icon.
The three buttons on the right, and the search pop-up (2026-09-27)
On the right of the box the buttons sit in one column: send on top, the microphone in the middle (bigger, and red so you can see it records), and the magnifying glass at the bottom. The + for files stays on the left.
- Typed something, then tap the magnifying glass - you land on your Blink Library with that search already done.
- Box empty, then tap the magnifying glass - a search window opens with the same choices as the Library's "Search & filters" bar: words, type, category, status, sort order and how many per page. Press Search to open the Library with those choices applied, or Cancel to close it.