Skip to content

A writing style you can pick or write, on each stage separately - #26

Merged
bojieli merged 11 commits into
mainfrom
feat/writing-style
Aug 30, 2026
Merged

A writing style you can pick or write, on each stage separately#26
bojieli merged 11 commits into
mainfrom
feat/writing-style

Conversation

@bojieli

@bojieli bojieli commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Stacked on #25#24#23#22. Review those first; this PR's diff is only its own changes.

What this changes, and why

Requested: a writing style you can either choose from presets or type yourself, configured
separately for dictation and for rewriting.

Two settings, because they are two jobs with opposite permissions. A dictation style decides
how the words are written down and may not change one of them; a rewrite style decides how they
are said again and is the stage allowed to reword. Chat for one and Formal for the other is a
legitimate combination and neither implies the other — which is why one control with a mode switch
would have been the wrong shape.

Each is filled either way. A dropdown covers the people one of a few answers fits, in one tap;
a text box covers everybody else. Neither alone is enough: three shipped rewrite styles are three
guesses at what somebody wants their email to sound like, and a blank box in a settings screen is a
question most people will not answer. Dictation offers As spoken, Chat, Notes, Prose, Custom;
rewriting offers Formal, Concise, Casual, Custom.

The custom text goes through the same host block a preset lands in.
prompt/dictation-style.md opens with the rule its clause could otherwise be read as relaxing —
this governs how it is written, never what it says — and then frames what follows as a style rather
than as speech: not to be transcribed, answered, continued or borrowed from. RewriteStyle.custom
substitutes into prompt/rewrite.md the same way, so never remove a fact covers the user's own
sentence exactly as it covers ours. A custom style that bypassed its block would be user-authored
text sitting unframed in a system instruction, which is the one thing this directory's rules exist
to prevent.

Two absences are load-bearing. spoken sends nothing, which keeps the measured changelog
describing the request a default install makes. And an empty Custom sends nothing either — a
rewrite instruction with a blank style clause asks the model to write in no particular way, and it
would do something — so secondStageInstruction returns nil for it in all three cores, and every
settings screen says what that means.

This generalises the formatting example from #24: same idea, now with presets beside it and a
matching control on the rewrite stage. prompt/sample.md becomes prompt/dictation-style.md and
its setting becomes the Custom entry.

Numbers, if this touches transcription quality

Nothing reaches a default request, and that equality is asserted rather than claimed
(testTheDefaultRequestIsUnchangedByTheseFeaturesExisting, and its C# port). Run as a control:

typography control (#24) translation control (#25) this
matched 38 36 38
improved 7 9 8
regressed 2 3 2

swift run dnt-eval suite eval/nearmiss --repeat-count 3, gemini-3.5-flash, 48 runs. Back to the
first control's figures, which is what an unchanged request should look like — and which puts
#25's 36/9/3 where it belongs: inside the noise the runner prints for itself.

Platforms

  • macOS
  • Windows
  • Android
  • iOS

Both settings and both custom texts travel in the transfer profile (optional in the format, so an
older profile still imports and keeps what the device has). rewrite:custom joins the mode grammar
in all three parity tables.

Verified on devices this time, which is new for this repo's Android side:

  • Android: 25 instrumentation tests on an API-35 emulator (./gradlew connectedDebugAndroidTest),
    including a new one for the dictation style picker that asserts choosing a preset does not throw
    away the custom text. This run is what caught a stale assertion in
    SettingsActivityTest — the rewrite picker offers four styles now.
  • iOS: xcodebuild test on a simulator, plus a new UI test that picks Chat, leaves Settings and
    comes back, guarding the failure a save-on-change setting has: fine on the screen, gone when you
    return.
  • macOS: built, signed, installed to /Applications and launch-checked, plus dnt prompt show
    printing the assembled instruction with a preset and with a custom style.
  • swift test (584), dotnet test (618) and ./gradlew test pass.

Two pre-existing failures, both reproduced on main.

DNT_INTEGRATION=1 swift test has one that is worth knowing about:
testScreenContextSubstitutionStaysWithinTheMeasuredRate fails roughly half the time. Sampled ten
runs — main passed 3 of 5, this branch 1 of 5 — and the system instruction the harness builds is
byte-identical between the two trees (diffed, 1143 bytes each), so this is the clip saturating
against the current default model rather than anything in the series. docs/PROMPT.md already
records real-talk-gemini15.wav losing its power to discriminate on gemini-3.5-flash; it appears to
have lost it on 3.6 as well, and the 0.85 threshold no longer holds. Left alone: moving a measured
threshold on ten samples is not something to do quietly. (Two more integration tests fail on an
OpenRouter 402 — the account is out of credits.)

One pre-existing iOS UI test still fails: testAPIKeySurvivesLeavingSettings, which reproduces
unchanged on main. I diagnosed it and did not fix it — the accessibility dump shows the settings
form pinned at Vertical scroll bar … value: 0% after all eight of reveal's scroll attempts on
the second visit to the screen, while the typed key is confirmed present in the field before
navigating away. So the assertion fails on a form it cannot scroll rather than on a key that was
not saved. Dismissing the keyboard first (Return, tapping the nav bar) and dragging instead of
swiping both failed to move it. Left alone rather than guessed at.

Follow-up from real use

Installed on a Pixel and the bottom row was reported as still too low. Measuring said the report
was right and the first fix was aimed one notch short: it padded by the navigation inset plus a
gap, and that inset is not a constant — gesture navigation reports 24dp where three-button reports
48dp, so the mode nobody complained about got 58dp of clearance and the mode in use got 34dp.

The rule is now a floor rather than a sum, with the floor set from Gboard: measured on the same
screen, Gboard's bottom key row ends 62dp above the bottom edge. This keyboard was at 34dp and is
now at 61dp. Gboard is the right target because it is what the complaint is implicitly comparing
against.

Below API 35 neither the floor nor the inset applies, because there the window sits above the bar
and a floor would be that many dp of dead keyboard — the opposite bug, and the one a floor invites,
so it has a test of its own.

Verified by screenshotting the emulator at each step rather than by reading the code: 34dp before,
49dp with the floor at 48, 61dp with it at 60.

That still was not it. Reported a third time, and clearance was never the whole problem. An
Android IME is anchored to the bottom of the display, so where a control sits within the bar is
where it sits under the thumb — and with a status label and a level meter above it, the one control
this keyboard exists for was the second-lowest thing on the screen.

The talk button now comes first in the bar, which lifts it about 60dp without making the keyboard
any taller, because the two rows it swaps with are the two nobody presses: one is a label and the
other is a readout. The meter follows the button rather than leading it — it reports on what the
button is doing — and the status line goes under both, where a sentence belongs.

This is a deliberate divergence from the iOS bar, which the two otherwise match on purpose, and
PARITY records why: iOS pins its keyboard to a fixed height the system already keeps clear of the
home indicator, so the same move buys it nothing there.

Measured on the reporter's Pixel 9 Pro Fold at 2.4375x: the talk button sits 176dp above the
bottom edge against 116dp before, and the utility row keeps its 59dp. Confirmed working by the
reporter on the device.

Checks

  • swift test, dotnet test and ./gradlew test pass
  • Comments explain why where a decision looks arbitrary
  • Nothing new is logged that could contain a transcript or a key

Cancelling an active dictation worked on every client already. On three of them
nothing said so, which for a feature reachable only by a key or a gesture is
the same as not having it: the desktops bound Escape and never printed it, and
both phones cancelled capture by dragging off the talk button before letting
go — undiscoverable, and with nowhere on a keyboard to be documented. What was
left in practice was to let a recording you did not mean finish, pay for it,
and delete what it typed.

Phones get a control, shown only while there is something to abandon and named
for which half of the dictation that is: Discard recording while the microphone
is open, Cancel transcription once the request has gone. Two names rather than
one because the two lose different things. On Android it is a cross beside the
talk button — deliberately not on the utility row, which is always there and
already has Backspace on it — and on iOS the keyboard's existing Cancel button
simply stops hiding itself during recording. Both hosts route through one new
DictationController.cancelActive(), because the state changes under the finger:
a request starts the moment a recording ends, and a control that went inert at
exactly that boundary would fail when it is most wanted.

The desktops name the key instead of growing a button. Their overlay ignores
the mouse on purpose — it hangs over whatever is being typed into, and a pill
that swallowed clicks would take them from the application underneath — so the
fix is that the pill says "Esc to cancel", in the recording phase and in the
transcribing phase, where the wait is longer and the way out matters more.
RecordingHint composes that row in the core of both desktops, with the same four
cases asserted in Swift and C#: the key's *name* stays with the client, because
macOS says Return where Windows says Enter, but the order and the join do not,
or the row reads differently on the two platforms.

swift test, dotnet test and ./gradlew test pass. The iOS UI tests pass with one
pre-existing failure (testAPIKeySurvivesLeavingSettings), which reproduces on
main; a new UI test covers discarding a recording. Android compiles and unit
tests; untested on a device.
The space between Chinese and Latin was whatever the model felt like on the
request. The same sentence came back spaced once and tight the next time, and
sometimes with a stray space after a full-width full stop, which no convention
allows anywhere. Every individual output was defensible; the set of them was
not, and inconsistency is what was reported.

The fix is split along the line between what can be decided locally and what
cannot.

Spacing is arithmetic, so it happens on the device. Typography runs in
TranscriptionService after both audio guards and before the result leaves —
the same choke point and the same argument as HallucinationGuard: dictation,
file transcription, retry, redo and both CLIs all come through it, so history
and the cursor cannot disagree, and a caller cannot forget it. One space at the
boundary is the default, with "no space" and "leave it as the model wrote it"
beside it; space next to a full-width mark is removed under both rules, because
that one is not a preference.

Three constraints keep it safe to run on every transcript. It may only add or
remove horizontal space — all three suites assert the input and output are
identical once whitespace is dropped. Newlines are not horizontal space, so it
cannot join two lines. And Hangul is outside the CJK class while kana is inside
it: Korean separates its own words, so "no space" would break the language,
whereas excluding kana would space Web開発 and not Webかいはつ, which is the
inconsistency the whole thing exists to remove. The 22-row table is repeated
verbatim in Swift, C# and Kotlin, like the mode grammar.

Script and punctuation are not arithmetic, so they are asked of the model.
prompt/typography.md carries a chosen Chinese script — Simplified against
Traditional was drifting inside single dictations — plus the request to
separate clauses with full-width punctuation rather than spaces. prompt/sample.md
carries the user's own formatting example, framed three times as an example and
not as speech, the way screen context is. Replacing a space with a comma stays a
request rather than a transform, because a comma the speaker did not say is a
content change and this project does not make those locally.

Both blocks are absent from a default request, and that is load-bearing rather
than tidy: every measured number in docs/PROMPT.md describes the default
request, and a clause added to it unconditionally would invalidate the whole
table at once. The equality is a test, not a claim.

Measured as a control rather than a before/after, since nothing sent a new
block: 48 runs, 38 matched, 7 improved, 2 regressed, against the 2026-08-19
baseline's 37/11/2 — inside the per-pass range the runner prints for itself.
The two regressions are the standing benefit-novel-repo failures.

The measurement harness turns typography off explicitly, which is the second
deliberate divergence in ARCHITECTURE.md: a suite scoring against ground truth
transcribed from the backend's output should not be scoring this app's own
transform.

swift test (577), dotnet test (588) and ./gradlew test pass; iOS builds and its
simulator tests pass. Android and Windows compile and unit-test; untested on a
device.
CI's `dotnet format whitespace --verify-no-changes` rejected an attribute left
on the same line as a property whose initialiser wraps. Caught by the Windows
source-validation job, which is the one part of the matrix a Mac cannot run.
A target language in Settings, off by default, on all four clients and from
`dnt transcribe --mode translate:English`.

This is the one setting in the product that makes the main control deliver
something other than what was said, so it is worth being exact about what it
does not change. The verbatim transcript is still produced first, stored first,
and recoverable — ⌘⌥Z, Ctrl+Alt+Z, the History row on both phones. That is the
actual promise; "the main key stays verbatim" was how it had been kept so far,
not the thing being kept. A translation you cannot expand back into the words
that produced it is the failure this project was built against. One you can is
a convenience sitting beside them.

Three decisions worth the review time:

It replaces the rewrite stage rather than joining it. One request doing two
jobs is the combination already measured at twice the substitution rate, and
"formal French" is a feature request rather than a fix for what was asked for.
Every client says so where the rewrite control is, through a new
RewriteAvailability.translating case, rather than leaving a picker that quietly
does nothing — the same reason that type exists at all.

It folds into the request that transcribes, wherever the backend can answer the
wider schema. Same argument as the rewrite: that request is the only one holding
the audio, and a translator working from text alone applies world knowledge and
"corrects" a version number it believes is stale. Recognisers, split recordings
and the live pipeline keep the second pass. StyledRequest replaces the bare
style-clause parameter for this: a rewrite and a translation are different jobs
asked for through the same field, and two optional parameters would have made
"both at once" a state somebody has to remember not to construct.

The language is free text with a shape check, exactly like the Model field and
for the same reason. "Traditional Chinese", "Brazilian Portuguese" and "plain
English" are all things a model can do and none is a row in an enum anybody
would have thought to add. TranslationTarget checks shape, never existence; the
suggestions are a shortcut and the suites assert they are not a whitelist.

prompt/translate.md is its own part rather than a rewrite style, because the
rewrite block's first rule is *keep the speaker's language* — the same
reasoning that keeps the summary separate, where the rule at stake is "never
remove a fact".

Measured as a control, since the transcription request is untouched: 48 runs,
36 matched, 9 improved, 3 regressed. The regressed count moved from 2 and the
honest reading is that it did not move — the runner printed `0–2 per pass` beside
it and named real-version-number, the third regression, as one of two cases
giving different answers across passes in that same run.

swift test (582), dotnet test (607) and ./gradlew test pass; the mode grammar
table gains eight translate rows in all three suites. iOS builds and its
simulator tests pass, with the same pre-existing testAPIKeySurvivesLeavingSettings
failure that reproduces on main. Android and Windows compile and unit-test;
untested on a device.
`prompt show --section system` printed the bare contract while the app appends
two more paragraphs to it, which makes the command useless for the one job it
has — telling somebody what is being sent. It now reads the same two settings
the app does, and gains a `translate` section beside `rewrite` and `summary`.

Verified end to end against a real code-switched recording: with the spacing
setting on `tight`, `一个retrieval pipeline,` and `这个index已经` come back
tightened while the space inside "retrieval pipeline" is left alone.
Found reading the branch back. The Windows tray built its "what is happening"
label as TranscriptMode.Rewrite(SecondaryStyle).ProgressLabel in two places,
which is a second answer to a question the controller had already answered — so
a translation showed "Loosening…" in the overlay and in the menu while a
translation was in flight. AppSettings.SecondStageFor is now the one definition
and both callers use it.

The Kotlin PromptPart carried two consecutive KDoc blocks on isClause, because
the new note was inserted ahead of the old one instead of into it.
Two settings, because they are two jobs with opposite permissions. A dictation
style decides how the words are written down and may not change one of them; a
rewrite style decides how they are said again and is the stage allowed to
reword. Selecting Chat for one and Formal for the other is a legitimate
combination and neither implies the other, which is why one control with a mode
switch would have been the wrong shape.

Each is filled either way. A dropdown covers the people one of a few answers
fits, in one tap; a text box covers everybody else. Neither alone is enough —
three shipped rewrite styles are three guesses at what somebody wants their
email to sound like, and a blank box in a settings screen is a question most
people will not answer. Dictation offers As spoken, Chat, Notes, Prose and
Custom; rewriting offers Formal, Concise, Casual and Custom.

The custom text is substituted into the same host block a preset lands in.
prompt/dictation-style.md opens with the rule its clause could otherwise be read
as relaxing — this governs how it is written, never what it says — and then
frames what follows as a style rather than as speech: not to be transcribed,
answered, continued or borrowed from. RewriteStyle.custom goes into
prompt/rewrite.md the same way, so "never remove a fact" covers the user's own
sentence exactly as it covers ours. A custom style that bypassed its block would
be user-authored text sitting unframed in a system instruction, which is the one
thing this directory's rules exist to prevent.

Two absences are load-bearing. `spoken` sends nothing, which is what keeps the
measured changelog describing the request a default install makes — asserted
rather than claimed, on all three platforms that can read prompt/ from a unit
test. And an empty Custom sends nothing either: a rewrite instruction whose
style clause is blank asks the model to write in no particular way, and it would
do something.

This generalises the formatting example from earlier in the series — same idea,
now with presets beside it and a matching control on the rewrite stage — so
prompt/sample.md becomes prompt/dictation-style.md and its setting becomes the
Custom entry. `rewrite:custom` joins the mode grammar in all three parity
tables, and both settings and both custom texts travel in the transfer profile.

swift test (584), dotnet test (617) and ./gradlew test pass. iOS builds; macOS
verified end to end through `dnt prompt show`, which now prints the instruction
a dictation would actually send rather than the bare contract.
Three things the emulator and a second read caught.

An empty second-stage instruction is now nil in the core rather than at four
call sites. A custom style with nothing in it, and a translation with no
language, both assemble to nothing — and a second stage sent with an empty
system instruction is a model asked to do something unspecified to a transcript,
which it would. Each settings screen now says what choosing Custom and typing
nothing means: no rewrite, and the transcript as it is.

The Android settings instrumentation test asserted the rewrite picker offers
exactly three styles. It offers four now, and that assertion is the reason the
list and the enum cannot drift, so it gains the fourth rather than losing the
check. A new case covers the dictation style picker, including the part worth
having: choosing a preset must not throw away the custom text somebody wrote.

The near-miss suite was run as a control for the third time in this series and
came back at 38/8/2 — the same figures as the typography control, which is what
an unchanged request should look like, and which puts the translation entry's
36/9/3 where it belongs: inside the noise the runner prints for itself.
…tlin

The picker is the control the formatting story now hangs off, and the failure it
guards against is a setting that saves on change but never reloads: fine on the
screen, gone when you come back. It follows the fidelity test's shape because it
is the same assertion about a different row.

Also drops a redundant `val mode = stage` alias and wraps the line it made too
long.
@bojieli
bojieli force-pushed the feat/writing-style branch from 8d193ea to 6642ef3 Compare August 30, 2026 11:16
Reported from a Pixel after the first fix shipped: the bottom row is still too
low. Measured on an API-35 screen at 2.625x, and the report was right.

The first fix padded by the navigation inset plus a small gap. That inset is not
a constant: gesture navigation reports 24dp — the home pill's strip — where
three-button navigation reports 48dp. So the mode nobody complained about ended
up with 58dp of clearance and the mode they were using got 34dp. The ergonomic
requirement does not vary like that. A thumb needs the same room away from the
bottom edge either way.

So the rule is a floor rather than a sum: never closer to the bottom of the
screen than MIN_BOTTOM_CLEARANCE_DP, and never closer to the navigation bar than
PAD_BOTTOM_DP. Below API 35 neither applies, because there the window sits above
the bar and a floor would be that many dp of dead keyboard — asserted, because
that is the opposite bug and it is the one a floor invites.

60dp is Gboard's, measured on the same screen with the same method: its bottom
key row ends 62dp above the bottom edge, against this keyboard's 34dp before and
61dp after. Gboard is the right target because it is what the complaint is
implicitly comparing against — whatever number this project reasons its way to,
the hand holding the phone is calibrated on Gboard.

The room comes out of slack that was already there. The status line and level
meter reserve about 60dp above the talk button and most of it is empty while
idle, so the bar was bottom-heavy before it was anything else.

Verified by screenshot on the emulator at each step rather than by reading the
code: 34dp before, 49dp with the floor at 48, 61dp with it at 60.
The third report of the same thing, and the first two fixes were both about
clearance underneath. Clearance was not the whole problem. An Android IME is
anchored to the bottom of the display, so where a control sits *within* the bar
is where it sits under the thumb — and with a status label and a level meter
above it, the one control this keyboard exists for was the second-lowest thing
on the screen.

Putting it first lifts it about 60dp without making the keyboard any taller,
because the two rows it swaps with are the two nobody presses: one is a label
and the other is a readout. The meter follows the button rather than leading it
for the same reason it was ever adjacent — it reports on what the button is
doing — and the status line goes under both, where a sentence belongs. The top
padding goes from 6dp to 12dp, because 6dp was right above a line of text and
leaves a 58dp capsule flush against the edge.

This is a deliberate divergence from the iOS bar, which the two otherwise match
on purpose, and PARITY records why: iOS pins its keyboard to a fixed height that
the system already keeps clear of the home indicator, so the same move would buy
it nothing there.

Measured on the reporter's Pixel 9 Pro Fold at 2.4375x: the talk button now sits
176dp above the bottom edge against 116dp before, and the utility row keeps its
59dp. Confirmed working by the reporter.
@bojieli
bojieli changed the base branch from feat/translation to main August 30, 2026 14:27
@bojieli
bojieli merged commit 006ed9a into main Aug 30, 2026
7 checks passed
@bojieli
bojieli deleted the feat/writing-style branch August 30, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant