XNU stops signalling a process as soon as it starts exiting but posts
NOTE_EXIT later in the same exit. A KILL that lands in that window gets
EPERM while the exit probe still reads "alive", so stopping a child that
TERM had just ended could fail with "Operation not permitted". This
flaked the macOS run of contained_exit_probe_preserves_a_live_child.
The EPERM branch now re-probes for up to 250 ms before treating the error
as a live, unsignalable root. Live roots, reaped roots and probe failures
are still errors.
Conflicts: CHANGELOG (main cut 0.5.2 and opened a new Unreleased; the
#2013 lines move there), pockettts/supertonic3 docs (main's new one-click
install text kept, with the retired "Model Catalogue → Engines →" step
dropped, as in the rest of docs).
Review fixes on top:
- Bulk installs name the right repository on failure: every "install
several" button now pairs allSettled results with their request before
filtering (shared failedInstalls/installFailureMessage + unit test).
- confucius4-tts docs: "The first synthesis triggers".
Bandit B202 flagged the custom filter callable. Links are now left out by
choosing the members, and extractall keeps filter="data", which validates
everything extracted. Same behaviour: the Matcha-TTS tarball's absolute
link is skipped instead of aborting the fetch.
Built the real environment on Windows to check the one-click install.
- The pins upstream carries with published advisories (diffusers,
hydra-core, lightning, modelscope, onnx, protobuf, transformers) are
raised to fixed releases; that exact set installs and passes the
installer's import probe. pyarrow, not needed, is dropped. A test keeps
every pin at or above its advisory fix.
- wetext is dropped with its install-time fetch. Its data exists only on
ModelScope, which rate-limits downloads; a throttled fetch left files
missing while reporting success, so the normaliser failed silently.
CosyVoice reads text as written without it, and nothing reaches
ModelScope at install or synthesis time. The unused post-install hook
goes with it.
- openai-whisper builds from source, and under a long cache path the build
hits Windows' 260-character limit; the install failure now says to turn
on long-path support.
The stdlib data filter raises on a link to an absolute path, and the
pinned Matcha-TTS tarball ships one (data -> its author's training
folder), which aborted the fetch and with it the CosyVoice install. Found
by building the real environment. Links are now skipped on every
interpreter, as the pre-3.11.4 path already did.
The guard now proves the job exits on a missing platform before it
rewrites the notes or publishes, so removing that exit fails CI.
RELEASING.md's end-to-end updater test still said to publish the draft by
hand; the workflow publishes it.
When OMNIVOICE_COSYVOICE_MODEL pointed at a folder that no longer existed,
the sidecar quietly loaded the installed CosyVoice 3 model instead, so it
spoke with a model and voice the user had not chosen while
model_identity() still named theirs. It now fails with a message naming
the path and how to fix or clear it.
Each build leg appended its checksums to the shared release notes with
softprops/action-gh-release. Two consequences, both seen on v0.5.2:
- The appends were concurrent read-modify-writes, so a leg that read the
notes before another wrote them lost its section. v0.5.1 and v0.5.2
both shipped without the macOS Apple Silicon checksums in the notes,
though the SHA256SUMS file was attached.
- softprops defaults to draft: false, so the first leg to finish
published tauri-action's draft while the others were still building.
v0.5.2 went public at 17:27; its complete latest.json landed at 17:38.
Legs now only attach their SHA256SUMS file with gh release upload. A new
release-notes-checksums job runs once after the matrix, the updater-manifest
repair and the uninstall scripts, writes all four platforms' checksums in
matrix order, fails if one is missing (leaving the release a draft), and
then publishes it. contributors-strip edits the notes after it, so the
notes have one writer at a time. A test pins all of that.
docs/RELEASING.md described a manual publish and .exe / .deb / .nsis.zip
artifacts the workflow no longer builds; it now matches what ships.
The one-section-per-version check matched raw heading text, so [v0.5.2]
and [0.5.2] would pass as two versions while core.changelog, which serves
the in-app notes, strips the v and whitespace and reads them as one. The
check now uses parse_changelog itself.
engine_venv_python() accepted any venv interpreter, so a reinstall that
failed partway made the resolver pick the sidecar and report it ready,
hiding the working in-process engine. It now also requires the completion
marker, which the import probe writes only after the engine imported in
that venv and which a new dependency step removes. That covers VoxCPM2
here and PocketTTS / Supertonic-3 from #2016.
The parent reads sidecar PCM at the engine's fixed 48 kHz, so the VoxCPM2
sidecar resamples a model that reports another rate instead of passing its
samples through mislabelled. The retry test now proves a permanent error
runs once.
Cut the Unreleased notes as 0.5.2 (2026-09-10) and fold in the untagged
0.5.2 section prepared on 2026-09-02, so release.yml publishes one
complete section. A test now requires one section per version.
The #1428 guard pins the exact set of sidecars it scans, and
docs/STRUCTURE.md must list every engine adapter; both failed CI on the
new engines/voxcpm2_subprocess.
CosyVoice ran only in-process, which needed it importable from the app's
own interpreter; upstream's setup (its own Python 3.10 environment, pins
that clash with the app's) never gives that, so the engine was unusable
from an installer build. The one-click installer now clones a reviewed
commit (074ca6dc) and its Matcha-TTS submodule (dd9105b3) into
DATA_DIR/engines/cosyvoice/, builds a Python 3.10 venv, downloads the
CosyVoice 3 weights, and a self-contained sidecar runs the model there.
Upstream's requirements cannot be used as they are: they add a
third-party Azure DevOps index, pin torch 2.3.1 (CUDA 12.1 only, no
RTX 50-series), pull TensorRT and DeepSpeed on Linux, and do not even
resolve together (fastapi). A trimmed list ships with the app
(engines/cosyvoice_subprocess/requirements.txt, which says what was
dropped and why); torch comes from the per-host 2.7.0 pins. Every package
installs from a wheel except two pure-Python ones, so nothing needs a
compiler, and SoX is not used.
The installer gains what CosyVoice needs, generally:
- ExtraSource fetches a pinned submodule tree, which neither a depth-1
clone nor GitHub's tarball includes;
- weights_allow_patterns downloads only the files the model loads
(5.4 of 9.8 GB);
- post_install_code runs an optional fetch in the engine's venv: here,
wetext's normalisation data, which it otherwise downloads from
ModelScope on every model load. A failure only leaves that to first use.
- The completion marker now applies to engines with weights too, since
weights left by an earlier run do not prove the dependency step
finished. IndexTTS keeps its weights check, so no existing install is
asked to reinstall.
The sidecar formats prompts as upstream's v3 examples do, speaks in
upstream's sample voice when there is no reference clip (v3 has no
built-in speakers), and never hands AutoModel a missing folder, which
would start a ModelScope download. The gateway keeps offering the
CosyVoice weights as a plain download to remote workers that run the
in-process engine.
Other suites purge services from sys.modules, so importing
subprocess_backend inside the test can yield a different module than the
one VoxCPM2SubprocessBackend subclassed, and the patch then misses: the
real generate() ran and failed. Patch VoxCPM2SubprocessBackend's own base.
The backend runs inside the app's tree, and the uv processes it starts for
engine installs had no working directory of their own. uv therefore
discovered VoiceStudio's pyproject.toml and applied its [tool.uv]
constraint-dependencies, torch==2.8.0 among them, to each engine's venv.
Resolved that way, MOSS-TTS-v1.5 (torch==2.9.1+cu128) and Confucius4
(torch==2.7.0) are unsatisfiable, so their one-click installs and
bootstraps could never succeed. An engine that pins no torch got the app's
instead of its own, so its venv was not really independent.
uv_subprocess_env, which every one-click install step and every engine
bootstrap already uses, now always sets UV_NO_CONFIG=1. It used to return
None in two cases and let the call inherit the environment; it now always
returns a copy. Region and custom mirrors still apply, because they reach
uv as UV_INDEX_URL. The app's own `uv sync` and the translation installer,
which install into the app's environment on purpose, are unchanged.
The translation uninstall route runs `pip uninstall -y <package>` on the
app's own environment. Two entries made that break something else:
- The LLM engine's package is openai, a core dependency that Settings →
LLM Providers also uses. Unlike Argos, it was not marked builtin, so
uninstalling that engine removed it from the app.
- Google, DeepL, Microsoft and MyMemory share deep_translator. Uninstalling
any one removed it for all four.
The route now asks uninstall_blocker() first. It refuses (400) a package
VoiceStudio itself depends on, read from the installed package metadata so
there is no second list to keep in step, and refuses (409) a package
another engine shares, naming the engines that would stop working. The
openai entry is marked builtin as well, and a test requires every entry
backed by an app dependency to be.
mlx_supported() fails three ways. Only the non-Apple branch is a platform
gap; Apple Silicon whose PyTorch cannot use MPS was left on the generic
line, and the live-host test asserted a platform reason even there, so it
would fail on such a Mac. That case now has its own owned sentence, and
the live test asserts only the branch its host can produce, with literal
cases covering the rest on every machine.
The in-process MOSS-TTS-Nano engine needs moss_tts_nano installed into the
app's own environment, with upstream's exact pins (torch 2.7.0,
transformers 4.57.1) landing there too. It also cannot work with today's
upstream: moss_tts_nano now exports only __version__, and the model class
the engine looks for is gone. The entry point is the top-level
moss_tts_nano_runtime.NanoTTSService.
The one-click installer now clones a reviewed commit (8b7bcc93,
2026-09-06) into DATA_DIR/engines/moss-tts-nano/ with its own venv. A
self-contained sidecar drives NanoTTSService there: it preloads the model,
heartbeats through the downloads (the model on load, the audio tokenizer on
the first synthesis) and never after, downmixes to mono as the in-process
engine did, resamples to the reported 48 kHz if needed, and reuses one
output file instead of leaving one per call.
The resolver becomes a table (_OWN_VENV_SIDECARS) now that two engines use
it, with a test that each entry's env var is the one its installer sets.
The three identical Intel-Mac gates become one factory.
VoxCPM2 ran only in-process, so using it meant installing voxcpm, and a
torch of its choosing, into the app's own environment. It now has a
one-click install into DATA_DIR/engines/voxcpm2/.venv and a self-contained
sidecar (engines/voxcpm2_subprocess) that imports nothing from the app.
tts_backend resolves the voxcpm2 id to the sidecar once that venv exists,
and to the in-process class otherwise, so an existing pip install keeps
working.
voxcpm leaves torch unpinned. Resolved with the CUDA index, that paired
PyPI's newest torch (CPU-only on Windows) with a +cu128 torchaudio, and
uv's --torch-backend fell back to voxcpm 1.5.0 on Windows. A new
torch_pins spec field pins the pair, and the host picks the build: +cu128
on CUDA hosts, +cpu on other Windows and Linux hosts, plain on macOS. Each
was resolved with voxcpm==2.0.3. Not offered on Intel Macs, where torch
2.11 has no build.
The parent keeps the reference-clip preparation and the trailing-silence
trim, so output matches the in-process engine. The sidecar retries a
transient weight download like the app's loader does.
An engine with no weights download counted as installed once its venv
interpreter existed, so a dependency install that died halfway made the
next attempt answer already_installed and the engine failed at its first
import. The import probe now writes a completion marker, and a fresh
dependency step removes the old one. IndexTTS keeps its weights check, so
no existing install is asked to reinstall.
The MOSS bootstrap no longer blames a non-CUDA host for an install
failure; the index is always supplied, and uv's error says what failed.
The PocketTTS and Supertonic guides now say where the sidecar runs, and
the three repository-engine guides say what to do if the first weight
download outlasts the compute-time budget.
The public reason sanitizer had no category for a host the engine cannot
run on at all, the same gap that hid the license button. "MLX requires
Apple Silicon" and PocketTTS's Intel-Mac reason became the generic
"check installation" line, and "not supported on this platform" became
"isn't installed yet", which an existing test asserted. Each sent people
after an install that could never work.
A platform category, matched after the license and before the install
and file checks, now says the engine doesn't run on this platform and
points at its guide. mlx-audio's "Apple Silicon only" wording is left
out of the markers: it also appears on an M-series Mac when the package
is simply missing, where installing does help.
Source validation demanded a pyproject.toml in every checkout, and
Confucius4 ships none, so its install could never get past fetching the
source. The manifest file is now per spec. The regression test fabricates
each pinned upstream's real root files.
Both engines ran with the app's interpreter, installed as optional extras
into the app's own environment (`uv sync --extra`). They now get one-click
installs like the sidecar engines: a PyPI-only spec (no source to fetch)
creates DATA_DIR/engines/<id>/.venv and installs the app's own pinned wheel
there, so nothing they install can touch the app or another engine.
Each engine prefers its own venv and falls back to the app's interpreter,
so an existing `uv sync --extra` install keeps working and is never
provisioned over: the spec counts a package found in the app environment
as installed.
PocketTTS installs from PyTorch's CPU index: it never uses a GPU, and
PyPI's Linux torch pulls ~15 NVIDIA packages. It stays unoffered on Intel
Macs, where no usable torch exists.
Supertonic's sidecar loads its constants by path when the revision env var
is absent, instead of importing the engines package, whose __init__
imports the app backend that its own venv does not have.
The Install button is hidden once only the license review stands between
the user and the engine. The installer tests' autouse fixture now removes
every spec's env var on teardown: a bare delenv of an unset var restored
nothing, and a persisted path leaked into later suites.
The Model Catalogue shows an engine's license Accept button only when its
reason matches /license not accepted/i. public_backends() replaces probe
text with owned sentences, and no category covered a license gate, so the
reason arrived as the generic "Engine unavailable" line and the only way
to enable Supertonic-3 or PocketTTS never rendered (#2017).
A license category, matched first, keeps those words. The test reads the
regex out of EngineCompatibilityMatrix.jsx, so a wording change on either
side fails CI instead of silently hiding the button.
Three engines that shipped as terminal-only setups now install from Model
Catalogue → Engines with the existing sidecar installer, which is
generalised to take a per-engine venv interpreter, install target, import
probe and host gate.
Each engine gets DATA_DIR/engines/<id>/ with its own checkout and .venv;
every uv pip install passes --python for that venv, never the app's
interpreter. Switching the active engine only changes a pref, so moving
between engines and back cannot corrupt a working one, and uninstalling one
removes only its own folder. Tests pin both invariants for every spec.
MOSS-TTS-v1.5's [torch-runtime] extra pins torch==2.9.1+cu128, which exists
only on PyTorch's index, so its manual install and its bootstrap could never
resolve (#2015). core.torch_indexes defines the index once for the
installer and the bootstrap, and a test ties it to the app's own
pytorch-cuda index.
Install buttons appear only where the install can work: MOSS on CUDA hosts,
dots.tts off Windows (upstream publishes no Windows install). A direct POST
on an unsupported host gets a 409 with the reason. An engine with no
one-click install now points at its guide, not at a page with no Install
button.
- SetupSummary: an installed engine whose routing is "unavailable" reads
Needs setup, not Ready (select is refused for it too); a failed /engines
or /dictation/models fetch renders as an error with Retry instead of
posing as "Off" / "Needs setup".
- Bulk installs (summary, model store, recommendation card) wait for every
request to settle before re-enabling, and report which repos failed —
one early rejection can no longer re-arm the button mid-flight.
- The weights list stays mounted across family switches (hidden under LLM)
so download progress and Retry/Dismiss state survive navigation.
- Settings search: Hugging Face mirror terms route to Network; the legacy
"models" tab id resolves to Storage. "Manage models" opens the TTS tab.
- Locales: uk "Рушії", zh-TW "引擎", vi "Engine" for the Engines heading.
- Docs name the family tab wherever the instruction depends on it.
Tauri applies platform config as a JSON Merge Patch, so the windows array in
tauri.macos.conf.json REPLACES the base one rather than merging window objects.
desktopWindowConfig.test.js pins that every base window property survives on
macOS, and it caught the base gaining "shadow": false without the overlay.
The programmatic builder already sets shadow(false) on every platform; this
keeps the macOS declaration in step with it. The Linux and Windows overlays
declare no widget window, so the base entry applies there unchanged.
The Model Catalogue put the same decision on two axes: an Engines pane with
TTS/ASR/LLM tabs and a Models pane with TTS/ASR/Dictation/Diarisation
sections, dictation shown in both, plus storage stats, the HF token and the
voice-preview toggle parked on the model list. Settings → Voice still carried
Engines and Models entries that only pointed back here.
Now the page reads top-down: a SetupSummary (speech, transcription,
dictation, language model — engine, device, one status word, Change), the
engine list for one family, and that family's downloadable weights under it
(TTS under TTS; offline ASR, streaming dictation and diarisation under ASR;
nothing for LLM, whose engines bring their own). One storage line points at
Settings → Storage.
- ModelStoreTab takes a `family` and scopes sections and the recommendation
preset to it (scopeReco); stats strip, HF-token toolbar and previews
panel removed from it.
- Settings: Engines/Models categories and CataloguePointer removed; models
directory → Storage, HF mirror → Network (both restart-flagged), voice
previews → Storage. "Manage models" in disk usage opens the catalogue.
- Store: openCatalogue takes a family (pane key tolerated, ignored);
pendingCatalogueTab gone.
- Engine matrix title is now the locale's plain "Engines".
- i18n: catalogue.* summary keys in all 21 locales; pane/pointer keys dropped.
- Docs: "Model Catalogue → Engines" is "Model Catalogue"; "→ Models" is
"→ Downloaded weights".
Greptile on #2010: if win.show() fails while hwnd() succeeds, the native
SW_SHOWNOACTIVATE show still ran, putting an always-on-top window on screen
that Tauri believes is hidden. dismiss() and the idle reconcile then cannot
remove it — the stranded-window bug this PR fixes, reached by a different
door.
The previous commit degraded to the native show on purpose, reasoning that a
visible pill beats none. That was the wrong trade: the tray's red dot already
tells the user they are being recorded, while an unhidable always-on-top window
is left behind for the rest of the session. The native show now runs only
after Tauri's show has succeeded, and the test that pinned the fallback is
flipped to pin its absence.
Two more defects from the same live report.
The card. Tauri's default window shadow on Windows gives an undecorated
window a 1px white border and, on Windows 11, rounded corners — drawn around
the whole 460x164 pill window, which is far wider than the pill (at most
284px). That is the bordered card framing empty space, visible whether or not
the pill is showing. The widget window now sets shadow(false), in the builder
and in its tauri.conf.json declaration; the capsule draws its own edge in CSS.
The stale model (#2012). The pill runs in its own window with its own store,
created at app start, usually before the backend listens. CaptureWidget
hydrated the dictation prefs once and memoized the promise whether or not the
load worked, and loadDictationPrefs swallowed the failure and marked itself
loaded. So the widget kept the store's seed, sherpa-whisper-tiny, for the
session. The main window checked the model actually picked (Parakeet,
installed) and said ready; the widget asked the server for the seed, and the
server correctly answered that it was not installed.
loadDictationPrefs now reports whether the backend answered. Only a successful
load is kept; a failed one is retried, and a capture start re-reads so a model
chosen in the main window reaches the widget. An in-flight load is shared.
On the same path, the missing-model install toast was called from the widget,
whose window has no <Toaster> in the desktop app, so it rendered nowhere. The
install recommendation now rides the dictation notice to the main window,
which shows the one-click download. The browser build, where the widget lives
inside the main window, keeps its local toast. The pill labels a missing model
as that rather than "Transcription failed: ...", and clamps error text to two
lines instead of spilling a paragraph past the capsule.
Tests: a failed first load is retried instead of pinning the seed, and a model
changed elsewhere is picked up at the next capture — both fail against main's
widget. The notice routes a missing model to the install toast. The
setup-race test now asserts no local toast in Tauri and the notice payload
instead. 91 frontend tests and 256 Rust lib tests pass; typecheck:ci passes.
Closing the dictation pill on Windows left an empty dark rectangle on screen,
always on top, removable only by quitting the app.
show_pill_noactivate called the raw Win32 ShowWindow(hwnd, SW_SHOWNOACTIVATE)
and never Tauri's own win.show(). The flag is there for a good reason (#982: a
pill that takes foreground makes the dictated text paste into the pill instead
of the user's document), but going straight to Win32 puts the window on screen
behind Tauri's back. Tauri went on believing it was hidden, and every mechanism
that could have removed it was disabled by that one desync:
- isVisible() answered false while the user was looking at the window;
- hide() was a no-op on a window Tauri thought was already hidden, so
dismiss() in CaptureWidget could not remove it;
- the idle reconcile — the backstop that exists precisely to clean up a
stranded pill — asks isVisible() first, and concluded there was nothing
to clean up.
win.show() now runs first, then the native flag. The no-activation behaviour is
carried by the WS_EX_NOACTIVATE style bit that mark_pill_noactivate applies at
creation, which is what makes the Tauri show safe: the style bit, not the show
flag, is what refuses activation. The flag stays as a second line of defence,
since hwnd() can fail and the bit might not have been applied.
Windows only. macOS and Linux already took the win.show() branch.
The ordering is now a function with both shows as parameters, so two tests can
pin it: Tauri's show runs and runs first, and a failing Tauri show still puts
the pill on screen — degrading to the old behaviour beats not showing the user
that they are being recorded.
256 Rust lib tests green; the 10 stranded-pill frontend tests unchanged and
still passing.
A staged task input's artifact id was built with os.path.join, so a Windows
control plane produced `inputs\<sha256>.wav`. That id is not a local path. It
is persisted into remote_tasks.params_json, shipped to remote workers over
gRPC as the identifier for the input they must fetch, and compared against a
later disk sweep to decide whether a staged file is still referenced.
So a Windows host hands a Linux worker `inputs\abc.wav`, where the backslash is
an ordinary filename character and no such file exists. Remote GPU workers are
a shipped feature; this broke them for every Windows control plane. The same
ids also stop matching when an omnivoice_data/ directory moves between
operating systems.
artifact_id_for() makes it canonical POSIX — resolve_within already treats both
separators as structural, so resolution is unchanged. normalize_artifact_id()
covers the upgrade: rows written by the old code carry a backslash, and the
sweeper decides "unreferenced" by comparing ids, so without it an upgraded
install reads every legacy row as garbage and deletes inputs that surviving
tasks still point at.
Two other tests in this run asserted POSIX-only behaviour rather than product
behaviour, and are corrected here too:
- the durability-barrier test required a directory fsync, which
_fsync_parent_directory deliberately skips without os.O_DIRECTORY. It now
gates on that same attribute rather than on the OS name, so the test and
the code it checks cannot drift apart.
- the read-only-cache test built its scenario with chmod(0o500), which on
Windows only toggles a read-only FILE attribute and does not stop a file
being created inside the directory. It verifies its premise by probing and
skips when the host writes anyway — which also covers root and anything
holding CAP_DAC_OVERRIDE, replacing a geteuid check that named only one of
them.
Then the reason none of this was visible: CI runs tests/ on Linux only. The two
worker suites join the existing Windows step in the smoke matrix. They need no
ffmpeg, so they cost seconds. Verified green on Windows first — 244 tests
across the four suites in that step.
Fails before, passes after, both directions: a staged id containing a
backslash, and a legacy-id input deleted by the sweeper.
main is red. Smoke (Windows) failed on
test_upload_durability_barrier_does_not_block_the_grpc_loop with
assert 0.20299999999997453 < 0.2
Three milliseconds of scheduling noise on a shared runner, and a red build
that says nothing about the product.
The three tests here prove a blocking filesystem call does NOT stall the gRPC
event loop: they park the call on a barrier and check the loop still ran their
own coroutine promptly. That is a wall clock on shared hardware, so the two
numbers have to be chosen against each other. The discriminator was a 0.5 s
watchdog — a stalled loop could not proceed until it fired — while the budget
sat at 0.2 s. Responsive measured ~0.2. The line was drawn exactly where the
noise lives.
Both numbers are named constants now, with the reasoning next to them:
a 1.5 s hold, a 0.75 s budget. Responsive lands near 0.2, stalled lands at 1.5,
and the line sits between them with room on both sides. The waiter's own cap
moved above the hold too, so a genuinely stalled loop is reported by the budget
assertion that names the problem rather than by a bare TimeoutError.
Verified the assertion is still worth having: with the blocking call made to
stall the loop for real, the test fails. It is a wider net, not a hole.
55 tests in the file, three runs in a row.
Both review bots found the same thing independently: `e.type !== 'respelling'`
badged rows the user had switched OFF. A disabled entry is indeed not applied,
but for a reason the toggle already shows — labelling it "not applied yet"
reads as a defect rather than their own choice. `inert_entries_for_language`
excludes disabled rows for exactly that reason, so the badge now matches it.
Not taken: the suggestion to name `ipa` and `cmu` explicitly instead of testing
against respelling. The backend's rule is that everything which is not
respelling is inert today, and mirroring it keeps the two in step. An explicit
list would silently stop badging a notation added later — an entry that saves,
validates, toggles on and quietly does nothing, which is the invisibility #1949
exists to remove. A test pins that direction with an unfamiliar type.
Two tests, one per direction. The disabled case fails without the enabled gate.
2860 vitest tests green.
Takes the part of #2002 by @utkarsha741 that #1984 did not already cover.
An IPA or CMU row saves, validates and toggles on, and is then dropped before
term matching — Phase 1 only substitutes respelling. #1984 made that visible in
the "Test a sentence" preview, which the user sees only if they run a test. The
entry LIST is where they look at what they have saved, and there it still
looked like every other working row.
So the row carries the same fact: a warning badge next to the type and scope
badges, on IPA and CMU only. Badging a respelling row would be the opposite
lie — those do take effect.
The rest of #2002 is already on main under a different name: it re-added the
backend skip detection and the test-preview line as `skipped_terms`, where
`inert_entries_for_language` and `inert_entries` have shipped since #1984.
Landing that half would have been a second implementation of one behaviour with
two response fields for it.
String added to all 21 locales, not just en, so the badge is not an English
island in a translated panel. Fails before, passes after: with the badge
condition disabled the new test cannot find it. 2858 vitest tests, 529 locale
and CJK guard tests green.