# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project status **Implemented, builds + tests pass, launches as a tray app.** The full pipeline is coded (hook → flip → paste) and the app runs in the system tray under the 50 MB budget. The copy→paste flip still needs interactive manual verification with a real selection (per the spec's manual-test plan); unit tests cover the pure logic. What exists: - `src/CyrFlip/` - the app (net48 WinForms): `TransliterationEngine`, `CaseFlipEngine`, `Hotkey`, `KeyboardHook`, `ClipboardHandler`, `CursorIndicator` (layout indicator), `Autostart`, `AppConfig`, `WindowInterop`, the `CyrFlipContext` tray shell, the `Launcher*` module family (the absorbed OneClickRunner scenario launcher, feature 5 below) and the text context menu (`MouseHook`, `MouseChord`, `SelectionProbe`, `TextContextMenu`, feature 7 below), plus the log bundle the user mails to the author (`SupportBundle`, `MailSender`, `SupportBundleDialog`) - all implemented. - `tests/CyrFlip.Tests/` - xUnit (`TransliterationEngine` + `CaseFlipEngine` + `Hotkey`). Internals are visible to tests via `InternalsVisibleTo`. - `assets/` - `cyrflip.ico` (app icon: text caret + EN/RU label), `banner.png`, `social-preview.png`, generated by `tools/IconGen` (run `dotnet run` in that folder to regenerate). - `docs/` - GitHub Pages site (served from `/docs`). `index.html` is the trilingual EN/RU/UA entry (a CSS `data-lang` switch); the **full guide stays EN/RU/UK** (`guide.html`, `docs/ru/`, `docs/uk/`), while each of the other 10 UI languages gets a **standalone first page** at `docs//index.html` with its own `lang`/`dir`, hreflang alternates and a 13-language switcher. The **privacy policy exists in all 13** (`docs//privacy.html`; `privacy.html` at the root is the English original and every translation links back to it as the authoritative text, saying outright that it prevails on any discrepancy) - it is the one page with an outside obligation, since the Store listing points at it in every language. All twelve translations are structurally identical (12 list items, 7 sections), which is the cheap way to catch a section dropped in translation. Those pages are generated copy - edit them directly, there is no build step. `winget/` - manifest templates. `.github/` - CI + release workflows, issue/PR templates, dependabot. `build.ps1` - local build + deploy. - `PLAN/` - the spec. The spec is in Russian and lives in [PLAN/done/KeyboardTransliterator_Specification_v1.0.md](PLAN/done/KeyboardTransliterator_Specification_v1.0.md) (moved to `PLAN/done/` now that it's implemented). The Markdown file is the single source of truth; the original Word `.docx` was dropped (recoverable from git history if ever needed). Note: the empty `dev/` directory predates the scaffold and is unused - code lives in `src/`. ## What CyrFlip is Per the user, the product has these features, in priority order: 1. **Headline: a live layout indicator where you type.** Three surfaces, all driven by `CursorIndicator.LayoutChanged` (which carries `(code, capsOn)`) and updated live: - the system **I-beam** mouse cursor is replaced with a caret carrying the EN/RU/UK marker (`LayoutCursor`, `SetSystemCursor(OCR_IBEAM)`); - a small marker **pinned next to the blinking text caret** (`CaretOverlay`) - because the mouse pointer is often an arrow while typing, so the caret is where the layout actually matters; - the **tray icon** (`CursorIndicator.RenderIcon`). When **CapsLock is on**, all three draw a **1px layout-colour frame** around the marker (`LayoutStyle.DrawCapsFrame`; a dark ring in the caret-overlay dot mode) so the CapsLock state is visible right where you type. 2. **Secondary: one table of layout→layout conversions (open-ended, not a fixed option).** Any number of rows, each "from layout → to layout" with its own global chord - e.g. Ctrl+Shift+F12 = EN⇄RU, Alt+Shift+F12 = EN⇄UK, Ctrl+Alt+F12 = RU⇄UK. **This is the only home of these chords**: the original EN⇄RU flip is not a separate feature any more, it is an ordinary row seeded on first run (`AppConfig.NeedsFlipRow` / `FlipRow`, called from `Load()`; US `00000409` ⇄ Russian `00000419` on `AppConfig.DefaultFlipHotkey` = Ctrl+Shift+F12), editable and deletable like every other. An older config is migrated once: the old `Hotkey` / `EnableFlipHotkey` values become that row's chord and switch, then both registry values are deleted. **Those two values, not the emptiness of the table, are the migration marker** (`AppConfig.NeedsFlipRow`) - seeding fires when the table value is absent *or* when it is present but empty while the legacy values are still there (the config a build that had the table but not yet the merge left behind). Once they are gone a table the user empties stays empty. Rows live in `AppConfig.LayoutConversionProfiles` (`LayoutConversionProfile`, JSON in one registry value), are edited on the **"Конвертация раскладок"** settings tab, and are bound in the hook by `KeyboardHook.UpdateConversionProfiles` (an immutable snapshot array swapped atomically, so the hook never walks a list the UI is editing). The conversion carries **no alphabet table of its own**: `KeyboardLayoutConverter` asks Windows for both layouts' mappings, so it covers whatever the user has installed, and each pair is **bidirectional**: when the pair's target layout is already active, `ClipboardHandler.ConvertLayout` applies it in reverse (`KeyboardLayoutConverter.IsActiveLayout`), so one chord fixes text typed either way - hence the "⇄" in the UI. The one exception is the **US ⇄ Russian pair**, which falls back to the hand-written `TransliterationEngine` table when Windows can't map the pair (the Russian keyboard was never installed) - CyrFlip's headline conversion has to keep working there, and it is the only pair we have a table for. `WorldLayouts.Popular` is the curated 13-language starter set (the world's ten most-spoken - EN/ZH/HI/ES/FR/AR/BN/PT/RU/UR - plus DE, IT and UK, with the common variants per language) behind the "Add 10 popular languages" button, and `WorldLayouts.CodeForKlid` is the shared KLID → two-letter code used by the table and the tray tooltip. 3. **Case flip (fix CapsLock).** A second global hotkey (default **Ctrl+Shift+F11**) copies the selection, inverts each letter's case (UPPER↔lower, `CaseFlipEngine`), and pastes it back - the "I left CapsLock on" fix. Same copy→transform→paste pipeline as the flip; its own user-configurable hotkey in the tray menu. Optional **"Flip CapsLock after the flip"** toggle (the case-flip counterpart of "Change the language after the flip"): after a successful case flip it also toggles the physical CapsLock key, so continued typing matches the corrected text. 4. **A self-contained "Языки Windows" tab (settings editor for Windows' input config).** Goal: the user never has to open the Windows "Language & region" pane. The tab in `SettingsForm` manages three Windows settings, all writing **system** state, not CyrFlip state: - **Keyboard layouts** (`InputLayouts.cs`) - install / remove / reorder / set-default any of the ~218 layouts Windows ships (incl. the Ukrainian keyboard). **Nothing is downloaded** - layout DLLs are already present; installing a *display-language pack* stays Windows' job (deep-linked via `ms-settings:keyboard`). - **The whole-cycle switch chord** (`Keyboard Layout\Toggle`) - a dropdown: Alt+Shift / Ctrl+Shift / `` ` `` / off. - **Direct per-language hotkeys** (`LanguageHotkeys.cs`) - "Ctrl+1 → English", "Ctrl+2 → Russian" etc., which **Windows handles, not CyrFlip** (`KeyboardHook` never matches or swallows them; they survive the app being closed). Motivating case: a mouse-macro presses Ctrl+1 and can then count on Latin input. See [PLAN/done/LanguageHotkeys_Spec_Idea_v0.1.md](PLAN/done/LanguageHotkeys_Spec_Idea_v0.1.md). Both modules take a one-time pre-CyrFlip registry snapshot (`AppConfig.LanguageHotkeysBackup` / `InputLayoutsBackup`) with a "restore" button, and warn honestly that a change may need a Windows sign-out/in to fully settle. 5. **Scenario launcher ("Быстрый запуск") - the absorbed OneClickRunner, opt-in and off by default.** `EnableScenarioLauncher=0` means CyrFlip behaves exactly as before the feature: no tray entry, no taskbar button, no Jump List tasks, hook snapshot empty. When enabled: user scenarios (program/script with args/workdir/ `RunAsAdmin`, or **yt-dlp** downloads that prompt for a link) run from **five surfaces** - the tray **Launcher submenu**, the settings table, an optional **per-scenario global hotkey** (a CyrFlip extension over OneClickRunner), and, on the **taskbar button** the feature brings with it (`LauncherTaskbarWindow`), both the **Jump List** on right-click (`LauncherJumpList`, hand-rolled COM `ICustomDestinationList`/`IShellLinkW` - no WPF; no explicit AUMID, portable rides the exe-path identity, MSIX the package identity) and the same list as an ordinary **menu on left-click**. A tray-only app has no taskbar button at all, which is why the Jump List used to be reachable only from a pinned shortcut. That button, and the header of the settings page, carry the icon OneClickRunner shipped (`LauncherBrand`) - the one place the absorbed program's own mark is kept, deliberately narrowing the "never OneClickRunner branding" rule of tech plan §2.3 to *scenario icons and the app's own identity*, which still use CyrFlip's icon. All five go through one `LauncherExecution.Launch` path (validation → interpreter → UAC only when `RunAsAdmin`; the untrusted yt-dlp link travels in an **environment variable**, never concatenated into the cmd line). Storage is **`%APPDATA%\CyrFlip\Scenarios`, one `{guid}.xml` per scenario**, XML-compatible with OneClickRunner's `AppItem` (plus the extra `Hotkey` element OneClickRunner ignores). A Jump List task starts a second `CyrFlip.exe` with `/launcher-run:{guid}`; it forwards the command to the live instance over the **named pipe** (`LauncherIpc`, `CyrFlip_Launcher_Pipe`) and exits - or, with no live instance, does a OneClickRunner-parity **one-shot launch without hook/tray** (releasing the single-instance mutex first, so an open yt-dlp prompt can't block a real launch). First enable offers a **one-time, non-destructive migration** from `%APPDATA%\OneClickRunner\Scenarios` (source never written; Guids preserved unless colliding) or seeds a localized Calculator sample; the `LauncherFirstEnableDone` marker guarantees an emptied list is never re-nagged or reseeded. The "Импорт из OneClickRunner..." button repeats the import deliberately. Spec: [PLAN/done/OneClickRunner_Absorption_Spec_Idea_v0.1.md](PLAN/done/OneClickRunner_Absorption_Spec_Idea_v0.1.md) (v0.2) + tech plan + `PLAN/OneClickRunner_Source_Manifest_v0.1.md` (SHA-256 of every source file). 6. **Built-in translator on a local Ollama ("Перевод"), opt-in and off by default.** `EnableTranslate=0` means no chord is bound, no tray entry, and **CyrFlip opens no socket at all**. Enabled, it is a second open-ended table - rows of "translate the selection into this language, on this chord" (`TranslationProfile`, `AppConfig.TranslateProfiles`, bound by `KeyboardHook.UpdateTranslationProfiles` with the same immutable-snapshot discipline as the conversion table, and included in the RDP-deferral condition). A row's target is a language code or one of two tokens resolved **when the chord fires**: `ui` (the CyrFlip UI language) and `active` (the layout live in the target window) - `TranslationLanguages.Resolve`. The answer streams into a popup **beside the mouse pointer** (`TranslationResultWindow`) that never steals focus, and can optionally be copied to the clipboard - where the history records it like any other copy, which is the point - or pasted over the selection. **The pipeline is three-phase on purpose** (spec §4): capture and delivery hold the existing `_busy` clipboard guard, the seconds-long model call runs **outside** it, so a translation never blocks the headline flip. Everything that owns the clipboard - the optional copy *and* the optional paste - runs on **one** guarded worker (`DeliverTranslation`): writing the result from the UI thread instead let a flip's copy poll pick the translation up as if it were the user's selection. Dismissing the popup (Esc, ×, the auto-close timer, switching windows) **cancels** a translation still in flight, and a result that arrives for a hidden window is dropped - otherwise an answer could paste itself into whatever the user moved on to. `TranslationService` owns the feature logic (prompt, one retry on an empty or echoed answer, model choice, the 4000-character cap), `OllamaClient` the protocol, `OllamaManager` the process. **An MSIX build never downloads or runs the Ollama installer** (`OllamaManager.CanInstallInPlace` - Store certification); it opens ollama.com instead. Ollama and the model are the user's own one-time install: CyrFlip bundles neither and has no key or account. Spec: [PLAN/done/Translator_Spec_Idea_v0.1.md](PLAN/done/Translator_Spec_Idea_v0.1.md). 7. **CyrFlip's own context menu over the selection ("Контекстное меню"), opt-in and off by default.** `EnableContextMenu=0` means the **`WH_MOUSE_LL` hook is not installed at all** - a hook that sits in the path of every mouse move on the machine must not exist for a feature nobody switched on. Enabled, a mouse chord (`MouseChord`, default **Ctrl + right click**, stored as an invariant token in `ContextMenuChord`) is swallowed - down, up **and** the double-click message, because most apps open their menu on `WM_CONTEXTMENU`, which arrives after the release - and CyrFlip's own menu opens at the pointer: Copy/Cut/Paste first, then the live conversion rows, the case flip and the translation rows, then the Launcher submenu and the history window, then Settings. The chord list is **fixed** (six options) rather than a capture dialog because the right button **must** carry a modifier, or CyrFlip would eat every context menu in Windows; `MouseChord.TryParse` rejects a bare right click even from a hand-edited registry. **Why our own menu and not Windows'**: the menu of a modern app (Electron, WinUI, WPF, Qt) is drawn by the app and has no `HMENU` at all, and editing a real Win32 `EDIT` menu needs a native DLL injected into every process - the end of "one exe, no dependency" and an automatic Store rejection. `TextContextMenu` is a pure static builder (`ContextMenuStrip`, whose `ShowWithoutActivation` is already true, so **the menu never takes the focus and the selection survives**), and the launcher submenu comes from the very same `LauncherTrayMenu.Fill` as the tray and the taskbar button. Greying follows `SelectionProbe` (§ below); a drop-down owned by a non-foreground process never sees the click that should dismiss it, so `MouseHook.ForeignButtonDown` closes it - `SetForegroundWindow` (the taskbar button's trick) is forbidden here, it would take the focus this whole feature exists to keep. Spec: [PLAN/done/ContextMenu_Spec_Idea_v0.1.md](PLAN/done/ContextMenu_Spec_Idea_v0.1.md). It runs in the **system tray** (the icon also shows the layout; right-click menu = the history controls, the three indicator toggles, the two keep-awake switches, the **Launcher** submenu and **"Перевести буфер обмена"** - each visible only while its module is enabled - then Settings and Exit). **Tray mouse:** a **single left click switches the input language to the next one in Windows' rotation** (`LayoutSwitcher.SwitchToNext`), a **double click opens Settings**. Since the shell also delivers the first click of a double click as an ordinary click, the switch is deferred by `SystemInformation.DoubleClickTime` and cancelled when the double click arrives - and it targets `CursorIndicator.LastActiveWindow`, not the foreground window, because clicking the notification area moves the focus to the taskbar. See [PLAN/done/Spec_v1.1_Cursor_and_Caret.md](PLAN/done/Spec_v1.1_Cursor_and_Caret.md) for the indicator design. **On the mouse cursor (important):** `SetSystemCursor` is **global** - it changes every app's I-beam until restored. The default cursors are reloaded (`SystemParametersInfo(SPI_SETCURSORS)`) on `Dispose`, `Application.ApplicationExit`, `AppDomain.ProcessExit`, and `UnhandledException` (see `LayoutCursor.ForceRestore`). The one unavoidable gap is a hard `TerminateProcess` (kill). Only `OCR_IBEAM` is replaced (not the arrow). The *displayed* I-beam only repaints on `WM_SETCURSOR` (mouse move), so after a layout change `LayoutCursor` sends a zero-delta `SendInput` mouse move (`ForceCursorRefresh`) to repaint it in place. **On the caret overlay:** the caret position comes from four sources, tried in order - (1) `GetGUIThreadInfo.rcCaret` (classic Win32 edit fields); (2) **COM UIA `IUIAutomationTextPattern2.GetCaretRange`** (`UiaCaretCom`) for **WinUI/UWP/WPF/modern-Notepad**; (3) **IAccessible2 `IAccessibleText.caretOffset`/`characterExtents`** (`Ia2Caret`) - the screen-reader caret API, and **the only source that locates the caret in Chromium/Electron** inputs (the VS Code chat box, browsers); (4) managed `TextPattern.GetSelection` as a last resort. Tracking runs on a **background MTA thread** so the cross-process calls never block the UI; the overlay window is touched only via `BeginInvoke`. It hides only where no source yields a caret (e.g. console windows). The tray menu's **"Diagnose caret position…"** dumps, for the focused element, what each source returns (log under the same MSIX-aware folder as `layout.txt`) - the way to debug "no marker here". > **Verified empirically (2026-06-21):** the VS Code chat input (`'Message input'`, ControlType.Edit, FrameworkId=Chrome) exposes **no Win32 caret, no `TextPattern2`, and a `GetSelection` that returns no caret rect** - so only the IAccessible2 path (3) places the marker there. It tracks live as you type (confirmed caret offset/x moving). Plain Chromium browsers behave the same; WinUI/native apps use paths 1-2. **COM vtable gotchas (`UiaCaretCom.cs`, `Ia2Caret.cs`):** both use hand-declared COM interfaces on Windows components (`CUIAutomation`, `oleacc`/IAccessible2) - **no new dependency**, so winget/MSIX shipping is unaffected. Method order must match the headers exactly: in `IUIAutomation`, **`ElementFromHandle`/`ElementFromPoint` sit between `GetRootElement` and `GetFocusedElement`**, so `GetFocusedElement` is **slot 6** (not 4) - getting it wrong silently calls `ElementFromHandle` with a bogus HWND and returns `E_POINTER`. `IAccessible` is laid out after IDispatch's 4 methods (`get_accName` slot 8, `get_accFocus` slot 16). Both modules guard calls with `[HandleProcessCorruptedStateExceptions]` so a vtable mishap degrades to the next source instead of crashing. **One deliberate deviation from the spec:** **tray app, not a Windows service.** A global `WH_KEYBOARD_LL` hook and the active-window/layout APIs only work in the user's interactive desktop session; a service runs in session 0 with no desktop, so AUTO/MANUAL service start types don't apply. "Autostart" is therefore a per-user `HKCU\..\Run` entry (`Autostart.cs`). ## Tech stack & hard constraints - **Target framework: .NET Framework 4.8** (`net48`), C# with `latest`. Chosen because 4.8 is preinstalled on every supported Windows 10/11, so the app ships as a single small `.exe` with **nothing to install** - the user's "full Win10 support, one EXE, no dependency" requirement. **Caveat:** net48 caps usable C# features (officially ~7.3); `latest` is enabled but features needing runtime support (records, `init`, ranges) require polyfills. This is a deliberate trade vs the spec's "C# 11+". - Built with the **.NET SDK** (`dotnet build`/`test`), not classic msbuild. net48 reference assemblies come from the `Microsoft.NETFramework.ReferenceAssemblies` package, so no VS targeting pack is required. - Heavy use of Win32 via **P/Invoke** (`[DllImport]`) - concentrated in `WindowInterop.cs`. This is the core of the app, not an edge case. - **No NuGet runtime dependencies** in the app project - config parsing uses the framework's `System.Web.Script.Serialization.JavaScriptSerializer` (a `System.Web.Extensions` reference), not `System.Text.Json`, to keep the exe self-contained without ILMerge. - Memory budget: **< 50 MB** idle. - Keyboard hook callback must be **non-blocking and < 1 ms** - do real work off the hook thread; never block inside the `WH_KEYBOARD_LL` callback. - `app.manifest` declares **PerMonitorV2 DPI awareness** (so rendered icons/labels are crisp on high-DPI) and Win10/11 `supportedOS`. ## Module architecture (`src/CyrFlip/`) Each class owns one concern (keep it this way - the spec prioritizes a minimal surface): - **Program.cs** - entry point (`[STAThread]` Main). Enforces **single instance** via a named mutex (`Local\CyrFlipSingleInstance`) - a second copy would install a second hook and fight over the system cursor - then runs `CyrFlipContext`. - **CyrFlipContext.cs** - the tray app shell (`ApplicationContext`). Builds the tray `NotifyIcon` + menu (history controls, cursor/caret/dot-mode toggles, keep-awake toggles, Settings, Exit), subscribes to `LayoutChanged(code, capsOn)` (forwarding the CapsLock state to all three surfaces), and on any hotkey runs the matching clipboard op via the shared `RunClipboardOp` helper **on a dedicated background thread** guarded by a single `Interlocked` `_busy` flag - it serializes every conversion and the case-flip (all own the clipboard) and blocks auto-repeat re-entry. Tracks usage via `AppConfig.IncrementFlipCount` / `IncrementCaseFlipCount`. The two fixed chords (case flip, clipboard history) are changed at runtime via `HotkeyDialog` and rejected when they clash with each other or with any conversion row (`Hotkey.SameChord`, `ConversionUsing`); the conversion chords are set from the table itself. - **KeyboardHook.cs** - `SetWindowsHookEx(WH_KEYBOARD_LL)` wrapper. The callback ignores injected events (`LLKHF_INJECTED`) so our own `SendInput` can't re-enter it, matches the chords via `GetAsyncKeyState` (`Matches(hotkey, vk)`), raises `CaseHotkeyPressed` (case flip), `ClipboardHistoryHotkeyPressed`, or `LayoutConversionHotkeyPressed(id)` (any row of the conversion table), and returns `1` to **swallow** the trigger key. **Every subscriber posts to the UI thread and does nothing inside the callback** (`CyrFlipContext` wraps all of them in `_ui.Post`) - file I/O, showing a window or starting a thread in there runs while the whole machine's keyboard waits. **`Reinstall()` exists because Windows drops a low-level hook that overran `LowLevelHooksTimeout` (~300 ms) and tells nobody**: there is no API to ask whether we are still hooked, so `CyrFlipContext._hookWatchdog` re-arms both hooks every 60 s (a WinForms timer, since a hook may only be removed by the thread that installed it), and three consecutive failures earn one balloon. That silent drop is the "my hotkeys stopped working after a while" report; the hook is also installed only *after* `_ui` is captured, or a chord pressed during startup would be swallowed and then dropped. Gated by a **master switch** (`_enabled` - when off the hook passes every key through) and **per-hotkey switches** (`_caseEnabled`/`_historyEnabled` for the two fixed chords; each conversion row carries its own `Enabled`). On a real chord match, if **`_deferInRemoteClient`** is on and `RemoteDesktop.IsClientForeground()`, it does **not** swallow the key - it lets the chord travel to the remote session (avoids the double-instance clash when CyrFlip runs on both ends of an RDP connection; see `RemoteDesktop.cs`). `Install(caseFlip, history, deferInRemoteClient, enabled, caseEnabled, historyEnabled)`; `UpdateCaseHotkey`/`UpdateClipboardHistoryHotkey`/`UpdateConversionProfiles`/`UpdateTranslationProfiles` and `UpdateEnabled`/`UpdateCaseEnabled`/`UpdateHistoryEnabled`/`UpdateDeferInRemoteClient` swap fields at runtime (safe from any thread - the callback reads the fields on each invocation). One key is watched that is **not** a chord: while `UpdateCancelKeyWatch(true)` is on - only while a translation is streaming - a bare **Escape** raises `CancelKeyPressed` and is **passed through**, never swallowed. The translation popup deliberately never takes focus, so it can never receive a key of its own; without this branch the "Esc to cancel" it promises could not work at all. - **MouseHook.cs** - `SetWindowsHookEx(WH_MOUSE_LL)`, installed **only while the text context menu is on**. Four rules are load-bearing: the callback sees **every mouse move** (up to 1000/s), so everything that is not one of our button messages leaves through a `switch` before a byte is marshalled - and a GC pause on this thread stalls the pointer system-wide, with Windows dropping a hook that exceeds `LowLevelHooksTimeout` (300 ms); nothing is **shown** from the callback (the subscriber posts to the UI thread - opening a menu inside a hook would run a foreign message loop there); **down, up and dblclk** are all swallowed; and the up is swallowed by the `_swallowUp` **flag**, not by re-checking modifiers, because the user often releases Ctrl before the button. `ForeignButtonDown` fires only while `UpdateForeignClickWatch(true)` (i.e. while the menu is open) - the outside click that dismisses it. - **MouseChord.cs** - modifiers + button, deliberately **not** a `Hotkey` (a keyboard chord always needs a trigger VK, a mouse chord never has one). Registry token is invariant (`Ctrl+RightClick`), the label is translated (`Display(translate)` - modifiers stay Latin, only the button name is a localization key). `Choices` is the fixed six-option list the settings dropdown offers. - **SelectionProbe.cs** - "is anything selected right now?", asked when the chord goes **down** and read when it comes **up**, so the 80-150 ms the user spends holding the button pays for the cross-process calls. Three sources in cost order: `EM_GETSEL` via `SendMessageTimeout` (classic Edit/RichEdit only - **gated on the window class**, since `EM_GETSEL` to a non-edit reaches `DefWindowProc` and returns a confident, wrong 0), then `Ia2Caret.TryGetHasSelection` (Chromium/Electron), then managed UIA `TextPattern.GetSelection` (a **degenerate** range = caret only = nothing selected). The result is three-valued and **`Unknown` counts as `Present`**: a greyed command beside a live selection is the failure the user sees, while a command that runs and does nothing is what "no selection → no-op" already means everywhere else. It **never** probes by synthesizing Ctrl+C - that would put text on the clipboard to draw a menu, and the history would record it as a real copy. - **TextContextMenu.cs** - the pure builder (`TextContextMenuState` in, `ContextMenuStrip` items out), so the whole "what is shown / what is grey / which separator disappears" matrix is unit-tested. Same copy-out-before-dispose discipline as `LauncherTrayMenu`, and for the same reason. - **ForegroundActivator.cs** - `Activate(form)` for a window opened from a surface that never held the focus. Windows grants `SetForegroundWindow` only to the foreground process or the receiver of the last input event, and **refuses silently**: `Form.Activate()` returns, the window is visible, and it sits behind the user's editor - which is exactly how "Настройки" in the text context menu read as broken. The fix is to share the foreground thread's input queue for the duration of the call (`AttachThreadInput`). Used by `ShowSettings`, `ShowHistorySearch` and `ClipboardHistoryWindow.ToggleVisible`. Every action of the text context menu is also deferred one message-loop turn (`CyrFlipContext.Defer`): inside the `Click` the drop-down is still tearing down and still holds the mouse capture. - **RemoteDesktop.cs** - `IsClientForeground()`: true when the focused window belongs to a remote-desktop client (`mstsc`/`msrdc`/`msrdcw`), resolved via `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` → `QueryFullProcessImageName`. Used only by the hook's RDP-deferral path, and only on a real chord match, so the process lookup never runs on ordinary keystrokes. - **Hotkey.cs** - parses `"Ctrl+Shift+F12"` → modifiers + VK (+ named keys: Space/Enter/F1-F24/..); `Display` round-trips it. `Hotkey.Default` is **Ctrl+Shift+F12** - the `Parse` fallback, and the chord the seeded EN⇄RU conversion row carries (Ctrl+Shift+T was dropped - it conflicts with browser "reopen tab" and Windows text-extraction tools). `Hotkey.CaseDefault` is **Ctrl+Shift+F11** (case flip). `SameChord` compares trigger key + modifiers (used to reject duplicate hotkeys). - **CursorIndicator.cs** - polls the foreground window's layout (`GetKeyboardLayout` → the HKL's language id → `WorldLayouts.CodeForLangId`, **the one place that decode lives**, so the marker and the settings tables can never call the same layout two different things; a language Windows cannot name comes back as its four hex digits, not as `"??"`) **and the CapsLock state** (`IsCapsLockOn`, `GetKeyState(VK_CAPITAL)` low bit) on a 150 ms `Timer` and raises `LayoutChanged(code, capsOn)` when either changes; also renders the tray icon (`RenderIcon(code, capsOn)`, GDI → managed `Icon` via a PNG-payload .ico, no leaked HICON). `LayoutChanged` drives all three surfaces (tray icon, `LayoutCursor`, `CaretOverlay`). The same poll remembers **`LastActiveWindow`** - the last foreground window that is neither ours nor a taskbar surface (`Shell_TrayWnd` & co, filtered by class name + process id) - because a tray click focuses the shell, so the tray's layout switch has to act on the window the user came from. - **LayoutCursor.cs** *(headline)* - renders the caret + EN/RU/UK marker to a `Bitmap`, turns it into a color cursor with a hotspot (`GetHicon` → `GetIconInfo` → `CreateIconIndirect` with `fIcon=false`), installs it via `SetSystemCursor(OCR_IBEAM)`, and nudges a repaint (`ForceCursorRefresh`). `Apply(code, capsOn)` rebuilds when the code *or* the CapsLock state changes (CapsLock adds the 1px pill frame). `Restore`/`ForceRestore` reload default cursors. Scaled by `config.cursorSize`. - **CaretOverlay.cs** *(headline)* - a borderless, topmost, click-through (`WS_EX_TRANSPARENT`), no-activate (`WS_EX_NOACTIVATE` + `ShowWithoutActivation`) `Form` with a rounded window region, showing the EN/RU/UK marker **diagonally below-right of the text caret** (so it never covers the current line). Supports two rendering modes: **text label** (EN/RU/UK letters, default) and **dot mode** (a small solid circle in the layout's colour - set via `SetDotMode(bool)`). `SetLayout(code, capsOn)` carries the CapsLock state; when on, the badge gets the 1px frame (text mode) or a dark ring (dot mode). A background MTA thread finds the caret via `GetGUIThreadInfo` (every ~90 ms) then a **throttled (~120 ms) cross-process fallback** that tries, in order, COM `GetCaretRange` (`UiaCaretCom`, WinUI/UWP/WPF), IAccessible2 (`Ia2Caret`, Chromium/Electron - VS Code chat & browsers), then managed `TextPattern.GetSelection`; it positions the form via `BeginInvoke`. Shows/hides with WinForms `Show()`/`Hide()` (relies on `ShowWithoutActivation` so it never steals focus). Passes an empty string to `SetLayout` to hide. The Monaco/VS Code *editor* caret is best handled by the companion extension; the *chat* box and browsers are handled by the IAccessible2 path. - **UiaCaretCom.cs** - hand-rolled COM interop to call `IUIAutomationTextPattern2.GetCaretRange` (absent from managed UIA). `GetFocusedElementSmart` = `GetFocusedElement` with a `GetFocusedElementBuildCache` fallback. See the "COM vtable gotchas" note above - the interface slot order is load-bearing. - **Ia2Caret.cs** - hand-rolled IAccessible2 interop (`oleacc.AccessibleObjectFromWindow` → drill `accFocus` → `QueryService(IAccessible2 → IAccessibleText)` → `caretOffset`/`characterExtents`). The caret source that works in Chromium/Electron where every UIA path fails. - **CaretDiagnostics.cs** - the tray "Diagnose caret position…" capture: 14 snapshots over ~7 s of every caret source for the focused element, written to `caret-diagnostics.txt` in the MSIX-aware folder. Run it while a problem input is focused to see exactly which source (if any) locates the caret. - **SupportBundle.cs / MailSender.cs / SupportBundleDialog.cs** - the About tab's **"Отправить логи автору.."**: pack the diagnostic logs into one ZIP and hand it to the user's own mail client. Three things here are decisions, not implementation details. **`clipboard-history.log` is never collected** - the file list is an explicit whitelist (`SupportBundle.LogFiles`), never a directory glob, because a glob is exactly how that file gets in one day; it has its own test, and so does the fact that its payload appears nowhere in the archive. **`mailto:` cannot carry an attachment** (RFC 2368 has no such field and `attach=` is deliberately ignored), so `MailSender.Send` is a three-rung ladder - Simple MAPI `MAPISendMail(MAPI_DIALOG)` with the file attached, else a `mailto:` message plus `explorer /select` on the archive, else the path and the address for the user to do it by hand - and `MAPI_USER_ABORT` **stops** the ladder, since a user who closed the compose window has finished, not failed. MAPI is ANSI, so a non-ASCII path (a Cyrillic Windows account name) goes through `GetShortPathName` or drops a rung rather than arriving as mojibake; a 32-bit Outlook answers our 64-bit process with `MAPI_E_FAILURE`, which is unfixable and is why rung 2 exists. **The dialog is the consent step** - file list, sizes, path, an "open the folder" button and the plain statement that the logs contain paths carrying the Windows account name - so there are no checkboxes and, deliberately, **no new registry values**: the feature has no state. Long logs are kept by their **tail** with a marker line (512 KB per file, 3 MB of collected bytes), archives live in `reports\` beside `layout.txt` (the MSIX-aware folder, because a mail client is a foreign process) and the five newest are kept. Subject and body are **English whatever the UI language is** - the artefact is addressed to the author - and the address is `sza@ukr.net`, the one already published in the site footer and the privacy policy. Nothing is ever sent automatically, not even after a crash. Spec: [PLAN/done/SendLogsToAuthor_Spec_Idea_v0.1.md](PLAN/done/SendLogsToAuthor_Spec_Idea_v0.1.md). - **LayoutStyle.cs** - shared marker look used by all three surfaces: per-layout colour (the curated 13 in `Curated`, EN=blue, RU=red, UK=green ..; any other code → a deterministic bright HSL colour from `BrightFromCode`) and `DrawCode` which renders the letters with that fill **plus a black outline** (`GraphicsPath.AddString` → `DrawPath` black pen → `FillPath`) so they stay legible on any background. `DrawCapsFrame` draws the **1px rounded layout-colour frame** that flags CapsLock around the marker. **This file is the source of truth for the palette, and the palette has exactly two other consumers, handled two different ways.** `tools/IconGen` **compiles this very file** (`` in its csproj), so it cannot disagree. The VS Code extension is packaged separately and cannot compile C#, so it reads `vscode-extension/src/layout-colors.json` - the machine-readable copy - and `LayoutColorsTests` fails the build when the two drift. That test exists because the drift already happened once and is invisible in both builds: the extension knew only EN/RU/UK and painted the other ten curated languages grey while the app drew them in colour. The fallback is an algorithm rather than a table, so the JSON also pins its constants and a handful of expected results, and the C# side is asserted against those samples. - **TransliterationEngine.cs** - `static`; two `Dictionary` (EN→RU, RU→EN). `Transliterate` auto-detects direction **per character**, so one pass fixes either direction and mixed text; case preserved; unmapped chars pass through. O(n). - **CaseFlipEngine.cs** - `static`; `Flip` inverts each cased letter (UPPER↔lower) via invariant-culture mapping, leaving digits/punctuation/whitespace untouched. Works for Latin and Cyrillic; it's its own inverse. The transform behind the case-flip hotkey (undo an accidental CapsLock). O(n). - **ClipboardHandler.cs** - the selection transform pipeline: back up clipboard → clean synthesized Ctrl+C (`SendInput`, releasing held modifiers first) → poll for the selection → apply the transform → cancel if the foreground window changed → set clipboard → Ctrl+V → restore clipboard. The core `Run(transform, toggleCapsAfter, targetKlid)` is shared: `ConvertLayout(profile, switchLayoutAfter)` passes the row's physical-key conversion (and, when the setting is on, switches the window to the layout the text now reads in), `FlipCase(toggleCapsAfter)` passes `CaseFlipEngine.Flip` (never switches layout; optionally toggles CapsLock after via `ToggleCapsLock` = synthesized `VK_CAPITAL` down+up). Clipboard ops retry 3× on lock (spec §5.3). Both halves are also public on their own - **`TryCaptureSelection` / `ReplaceSelection`, with `BackupClipboard`/`RestoreClipboard` around them** - because the translator needs seconds of network time between them and cannot hold the clipboard that long; `Run` is exactly those two back to back, so the flips keep their original single-backup behaviour. **The backup covers three formats, not just text** (`CF_UNICODETEXT` + `CF_DIB` + `CF_HDROP`, handed back in one open/empty/refill pass via `Win32Clipboard.Restore` - restoring them one at a time cannot work, since each write empties the clipboard again): the copy half ends in `EmptyClipboard`, so backing up text alone meant a user who had a screenshot or a set of copied files on the clipboard and then fixed a word with a chord **lost them for good**. `CF_DIB` is what covers images, because Windows synthesizes `CF_BITMAP`/`CF_DIBV5` from it; an image over `MaxBackupImageBytes` (64 MB) is skipped rather than carried through memory twice on every flip. The modifier state the user was physically holding is captured **before** the synthesized Ctrl+C (`HeldModifiers`) and passed to the replace half, since our own key-ups make `GetAsyncKeyState` report them released. Runs on the dedicated background clipboard thread (MTA - `Win32Clipboard` is raw Win32, not OLE, so no STA/message pump is needed). - **ClipboardHistoryService.cs (+ `ClipboardHistoryWindow` / `ClipboardHistorySearchWindow`)** - the opt-in clipboard manager: a `NativeWindow` listening on `AddClipboardFormatListener`, an append-only log (`%LOCALAPPDATA%\CyrFlip\clipboard-history.log`, each `add` payload DPAPI-protected per user) and the in-memory list the strip and the search window paint from. **Accepted design decision (2026-07-26, after a leak audit): the history is deliberately unbounded.** There is no entry cap, no age-based retention and no log compaction - the whole point is that nothing the user copied is ever silently dropped, and the memory/disk cost of that is the user's to spend. Do **not** "fix" this by adding a limit; it is a decision, not an oversight. What the decision *does* oblige is that nothing may cost O(history) per copy or per repaint - and that obligation is now discharged by **`ClipboardHistoryOrder`**, a separate class holding the list, the `_byUuid` index and the "what the clipboard holds now" flag: the display order is **maintained incrementally** (a binary search plus one list move, no comparers and no allocation) instead of the whole history being re-sorted after every copy, and `SetCurrent` touches only the two entries whose flag changes instead of walking every one. Each change still raises `Changed` exactly once. Splitting it out is also what makes the order unit-testable without a `NativeWindow`, the registry or DPAPI: `ClipboardHistoryOrderTests` replays a thousand random operations and compares against that very reference sort after each one. The window persists its position **once per drag** (`MouseUp`), not on every `Move` - each save is a full `AppConfig.Save()`, i.e. forty-odd registry values plus both profile tables serialized. - **Autostart.cs** - `HKCU\..\Run` toggle (per-user; not a service - see deviations). **Packaged (Store) builds are different in both directions:** the `Run` write is virtualized, so `Set` is a no-op and the settings checkbox opens *Settings ▸ Apps ▸ Startup* instead - but the state is **read** from the startupTask record (`..\AppModel\SystemAppData\\CyrFlipStartup\State`, `StartupTaskState` 2/4 = on) and re-read when the settings window is activated. Returning a hard `false` there is what made the checkbox look broken: it opened the system page and then "unticked" itself even though Windows was starting CyrFlip. - **KeepAwake.cs** - the two "don't sleep / don't blank the screen (like video)" toggles. Two independent switches (`KeepSystemAwake`/`KeepScreenOn`) OR-ed into one `SetThreadExecutionState` call: `ES_SYSTEM_REQUIRED` (system won't sleep) and `ES_DISPLAY_REQUIRED` (display won't blank/lock on idle). `ES_CONTINUOUS` makes the request sticky (one call per change, no polling); it is bound to the calling thread (the tray UI thread), so a hard kill clears it for free, and `Reset()` restores the normal idle policy on exit and when both are off. **Both switches are persisted** (`AppConfig.KeepSystemAwake`/`KeepScreenOn`) and re-applied at startup by `Restore(system, screen)` - **one** call carrying both bits, never two, so a launch with both on never asks Windows for half the policy first. That reverses spec §5's original "memory only, off on every launch" (changed 2026-07-28): a switch that silently forgot itself read as broken, which is exactly how it was reported. The old safety argument now lives in the UI text instead - the settings hint says outright that a forgotten switch keeps the machine awake after a restart too and that CyrFlip will not watch your battery for you. `KeepAwake` still persists nothing itself: `CyrFlipContext` writes the config and hands the state back, so the P/Invoke seam (`SetExecutionState`) stays the only thing to fake in tests. No admin rights; the two registry values are ordinary `AppConfig` state. Driven by two checkable tray items + two "General"-tab checkboxes; see [PLAN/done/KeepAwake_Spec_Idea_v0.1.md](PLAN/done/KeepAwake_Spec_Idea_v0.1.md). **Only `powercfg /requests` (admin) can confirm the request landed** - `SetThreadExecutionState` returns the previous state, never a confirmation; `tools/uitest/Test-KeepAwake.ps1` is that check. - **WindowInterop.cs** - all `[DllImport]`s + interop structs. The `INPUT` union includes `MOUSEINPUT` so `Marshal.SizeOf(INPUT)` matches the real struct on x64 (else `SendInput` silently fails). - **AppConfig.cs** - persists settings to `HKCU\Software\CyrFlip`. On first run with no registry key, migrates from a legacy `config.json` (from `%APPDATA%\CyrFlip\` or beside the exe) and seeds the conversion table (`NeedsFlipRow`/`FlipRow`, called from `Load()`; see feature 2). Fields: `CaseHotkey` (default **Ctrl+Shift+F11**), `CursorSize`, `EnableCursorChange` (default **false**), `EnableCaretOverlay` (default **true**), `CaretDotMode` (default **false**), `EnableLanguageSwitch` (default **false**), `FlipCapsLockAfter` (default **false**), the hotkey switches `EnableHotkeys`/`EnableCaseHotkey`/`EnableHistoryHotkey` (all default **true**), `DeferToRemoteDesktop` (default **false**), `FlipCount` + `CaseFlipCount` + `TranslateCount` (usage counters), and the translator block (`EnableTranslate`, `TranslateSeeded`, `TranslateProfiles`, `TranslateEndpoint`, `TranslateModel`, the timeouts and the result-window options - see the config table). `UiLanguage` holds the language **endonym** ("Русский", "Deutsch", "中文", ..) and defaults to the OS UI language via `Localization.DefaultLanguage()` (any of the 13 translated languages, else **English** - never Russian, which is only the translation table's source language). The **user's display language** (`CurrentUICulture`) wins over the language Windows was installed in (`InstalledUICulture`), which is the only one that is right for an English Windows switched to a Russian desktop; regional variants resolve to their language, so de-AT, pt-BR and zh-Hans-CN all find their translation. `SettingsTab` remembers which settings page was open (restored on the next launch too, clamped against the live page count - the tab strip grows between versions). `Save()` writes all fields; `IncrementFlipCount()`/`IncrementCaseFlipCount()`/`IncrementTranslateCount()`/`SaveSettingsTab()` write only that one value (cheap). - **Localization.cs (+ `Localization.*.cs` partials)** - the **single localization layer** for the whole app: settings window, tray menu and every dialog read from it, so adding a language is one column instead of a ternary in each file (the old `ru ? ... : uk ? ...` pattern is gone). The **key is the Russian source string** - call sites stay readable and no resource ids are needed - and the value is one translation per language. `Names`/`Codes` list the **13 UI languages** (the curated world set plus Russian, matching `WorldLayouts.Popular`); an unknown key returns the source unchanged and an empty translation falls back to English, never to Russian. Three helpers carry the script-specific bits: `IsRightToLeft` (ar/ur → `RightToLeft` + `RightToLeftLayout` on every form), `FontFamily` (Nirmala UI for hi/bn, Microsoft YaHei UI for zh; null = the default font is fine) and `WidthScale`/`Scaled` (the tables' fixed pixel columns were measured against Russian, and German or Devanagari overflow them). `SettingsForm.ApplyScript` applies all three on every language change, and `RefreshBoldFonts` re-derives the section headers, whose explicit **bold** font would otherwise not inherit the new family. **Never lay a dialog out by pixel coordinates.** Captions exist in 13 languages and are drawn at whatever display scaling makes of the UI font, so fixed geometry clips them - the bug that had "Assign" rendering as "Assig" and `Ctrl+Shift+F12` as `Ctrl+Shift+F1`. `HotkeyDialog` and `LayoutConversionDialog` use an auto-sizing `TableLayoutPanel` in an `AutoSize` form; `SettingsForm.RowHeight` derives from `Font.Height` and `MeasureConversionColumns` measures the text that is actually going to be drawn. `DialogLayoutTests` guards it. - **HotkeyDialog.cs** - a modal `Form` (`FormBorderStyle.FixedDialog`) that captures a new hotkey from the user. `KeyPreview = true`; requires at least one modifier (Ctrl/Shift/Alt) plus a trigger key (A-Z, 0-9, F1-F24, or named keys). The constructor takes an optional window title (so the case-flip dialog reads "Set case hotkey"). Returns the hotkey string via `CapturedHotkey` on `DialogResult.OK`. - **InputLayouts.cs** - installs / removes / reorders **Windows keyboard layouts** for the "Языки Windows" tab (no downloads - the layout DLLs ship with Windows). Win11 keeps input layouts in **two** stores at once: legacy `HKCU\Keyboard Layout\Preload`+`Substitutes` (read by `GetKeyboardLayoutList`) and the modern, authoritative `HKCU\Control Panel\International\User Profile` (a `Languages` BCP-47 multi-string + a `:=1` value per keyboard). The modern store re-syncs the legacy one on sign-in, so both are rebuilt from one canonical ordered KLID list on every change (`Persist`); the live session is driven by the documented `LoadKeyboardLayout`/`UnloadKeyboardLayout`. Preload can't list two layouts of one language, so the 2nd+ use a `d` device handle via `Substitutes` (`BuildPreload`, unit-tested). A KLID's low 4 hex = its language id. `BackupAll`/`RestoreAll` snapshot both stores; the "restore" is byte-exact (verified by a temporary live round trip: add Ukrainian → verify Preload+profile+installed-list → make-default → remove → restore → identical). `LayoutPickerDialog.cs` is the type-to-filter "add layout" picker. - **LanguageHotkeys.cs** - editor for **Windows'** own "switch straight to this input language" chords, stored in `HKCU\Control Panel\Input Method\Hot Keys\` (ids `0x100`-`0x11F` = `IME_HOTKEY_DSWITCH_FIRST/LAST`), each a subkey with three 4-byte little-endian `REG_BINARY` values: `Key Modifiers` (a `MOD_*` mask), `Virtual Key`, `Target IME` (the destination HKL). Written values are re-read by the OS via `SystemParametersInfo(SPI_SETLANGTOGGLE)`. **CyrFlip does not process these chords** - the hook doesn't look at them; that's the whole point (they survive the app being closed). Two things worth knowing: the format stores an *arbitrary* modifier mask + VK, so **plain `Ctrl+1` is representable even though the built-in Windows dialog only offers `Ctrl+Shift+digit` / `Left Alt+Shift+digit`** - that's the feature's added value; and ids outside the direct-switch range are Windows' built-in IME chords (`0x10`, `0x70`, `0x200`..) which are never touched. Windows ships orphaned records (e.g. `0x104` → a Japanese HKL that isn't installed), so the UI surfaces those for removal instead of clobbering them. `AppConfig.LanguageHotkeysBackup` holds a one-time JSON snapshot of the pre-CyrFlip state so "restore" always means the state the user arrived with. - **KeyboardLayoutConverter.cs** - the engine behind the conversion table: converts a string **by physical key position** between two installed layouts, with no alphabet table of its own (except the US ⇄ Russian fallback described in feature 2). `VkKeyScanEx` (source layout) gives the VK+modifier state that produces the character; the VK is turned into a **scan code in the source layout** and read back as the **target layout's VK** (`MapVirtualKeyEx` both ways) - the step that makes AZERTY/QWERTZ correct, since French `a` sits on the US `q` key. `ToUnicodeEx` then renders it in the target layout. Two non-obvious rules: a negative `ToUnicodeEx` result means a **dead key latched inside the layout**, so it is flushed with Space (else the user's *next real keystroke* comes out composed), and the original character is kept whenever the result isn't exactly one char (IME/composed text is left alone). `ResolveInstalled` is the only way KLIDs become HKLs here: `LoadKeyboardLayout` *adds* an unknown layout to the user's Windows input list, so anything not already installed is unloaded again and the conversion simply does nothing rather than silently reinstalling a layout the user removed. `LayoutSwitcher.SwitchTo` goes through the same resolver. - **LayoutConversionProfile.cs** - one table row (`SourceKlid`, `TargetKlid`, `Hotkey`, `Enabled`, plus an `Id` that survives edits) and, beside it, `WorldLayouts`: the curated ten-language KLID set, `LabelForKlid`, and `CodeForKlid` (curated code, else the language id decoded through the OS - a non-curated layout still reads as e.g. "PL", never as a raw KLID). `LayoutConversionDialog.cs` edits a row and rejects source == target. - **LayoutPublisher.cs** - writes the current layout code to `%LOCALAPPDATA%\CyrFlip\layout.txt` on every change, so the companion VS Code extension can place the marker at the editor caret (the external overlay can't track Monaco's caret reliably). - **OllamaManager.cs** - the *process* side of the translator: find `ollama.exe` (the usual install folders, then PATH), is it running, start it (`ollama app.exe`, else a hidden `ollama serve`), download and launch the official installer. `CanInstallInPlace` is `!PackageInfo.IsPackaged`: **a Store build never fetches or runs an installer**, its button opens ollama.com. The download forces **TLS 1.2** on `ServicePointManager` - net48 otherwise negotiates SSL3/TLS1.0 and ollama.com drops the connection with an opaque error. - **OllamaClient.cs** - the *protocol* side, behind `IOllamaTransport` so every parser above it is unit-tested without a server (`HttpOllamaTransport` is the real one: one shared `HttpClient`, per-request timeouts on a linked token, and a `CancellationTokenRegistration` that disposes the response stream because net48's `ReadLineAsync` takes no token - without it Esc would only be honoured after the model finished its sentence). `ProbeAsync`/`ListModelsAsync` (`GET /api/tags`), `PullModelAsync` (`POST /api/pull`, NDJSON progress), `GenerateAsync` (`POST /api/generate`, `stream:true`, chunks handed to a plain `Action` on whatever thread the read completed on - **marshalling is the caller's job**, an `IProgress` created down here would capture the wrong context). - **TranslationService.cs** - the feature logic: make sure the server is up (one auto-start attempt, ~8 s), resolve the model (the configured one if installed, else the best installed by `PickPreferredModel`), build the prompt, stream one completion, and judge the answer. The judgement is deliberately small (spec §5.3): an **empty or echoed** answer earns exactly one retry with a firmer prompt, and a bad retry keeps the first answer. `MaxChars = 4000`; longer selections are cut and the window says so. `TranslationSink` is two callbacks (chunk / reset) rather than an `IProgress`, because a retry has to clear what the first attempt already painted. - **TranslationProfile.cs** - one table row (`TargetLang`, `Hotkey`, `Enabled`, an `Id` that survives edits) and, beside it, `TranslationLanguages`: the two live tokens (`ui`, `active`), `Resolve` (token → concrete code at press time), `EnglishName` (what goes into the prompt - a curated table, else the OS name, and never Windows' literal "Unknown Language") and `Label` (the endonym the UI shows). **`AllCodes` is every language Windows knows**, and the pickers offer all of it. It used to be `Localization.Codes` - the 13 *interface* languages - which fused two unrelated sets: measured on a live Ollama, that list was wrong in both directions, offering languages the default model garbles (Ukrainian, Hindi, Bengali, Urdu on `qwen2.5:3b`) and hiding Japanese, which it does perfectly. **CyrFlip claims nothing about coverage and blocks nothing:** there is no source to claim from (the GGUF `general.languages` key is null or absent on every model checked, and a model page sometimes gives a count with no list), and coverage belongs to the model, not to us - we dispatch, it translates. `ModelPageUrl` builds the link the UI offers instead (`qwen2.5:3b` → `ollama.com/library/qwen2.5`; a name with `/` came from outside the library, so it goes to the index). The browser makes that request, not the app - CyrFlip still opens no socket of its own. - **TranslationResultWindow.cs** - the popup beside the mouse pointer: a `SizableToolWindow` (native close button and resize grip, no taskbar/Alt+Tab entry) with `ShowWithoutActivation` so it never steals focus, built from real controls in a `TableLayoutPanel` so `DialogLayoutTests` can prove nothing is clipped in 13 languages. A window that never activates never gets `Deactivate`, so "close when the user moves on" is a 400 ms poll of the foreground window, armed only **after** the answer is in - closing it mid-translation would throw away what the user is waiting for. - **TranslationDialog.cs** - the row editor (language + chord), same auto-sizing `TableLayoutPanel` shape as `LayoutConversionDialog`. - **Launcher (absorbed OneClickRunner), one concern per file:** - **LauncherScenario.cs** - the model. `[XmlRoot("AppItem")]` + identical field names = the OneClickRunner XML contract; `Hotkey` is the one CyrFlip extension; **public** only because net48 `XmlSerializer` refuses internal types. `SPECIAL_YTDLP` legacy sentinel → `IsYtDlp`. - **LauncherScenarioStore.cs** - file-per-scenario store (`%APPDATA%\CyrFlip\Scenarios`, folder created lazily on first write). Corrupt file → skipped + counted in `LoadErrors`, never fatal. Contiguous `Order` normalization à la OneClickRunner (name breaks legacy all-zero ties); temp-file-then-replace saves; `Import` always fresh Guid; `SeedSample` only into an empty store (caller guards with the one-time marker). - **LauncherMigration.cs** - read-only import from `%APPDATA%\OneClickRunner\Scenarios`: Guid preserved unless colliding (then fresh + counted), unreadable files listed by name, source **never** written. - **LauncherExecution.cs** - the single launch path (port of `ScenarioLauncher`): `TryResolveTarget` (file / http(s) / workdir-relative / PATH+PATHEXT) before `Process.Start`; `runas` iff `RunAsAdmin`; UAC decline (Win32 error 1223) = cancel, not failure; yt-dlp = `cmd /k` console with the link **only in `CYRFLIP_YTDLP_LINK`** (quote/control chars rejected). Error strings are Russian-source keys passed through a `translate` seam. - **LauncherScriptInterpreter.cs** - `.ps1` → pwsh (probed) else Windows PowerShell, `-NoProfile -ExecutionPolicy Bypass -File` last; `.bat`/`.cmd` → `cmd /s /c ""script" args"` (the one quoting that survives spaced paths AND quoted args). Do not "simplify" the quoting. - **LauncherIconResolver.cs** - exe/own icon, scripts borrow the interpreter's, fallback = CyrFlip's own icon (never a blank Jump List task, never OneClickRunner branding); + `LauncherIconCache` (display-only, extraction failure never blocks a launch). - **LauncherIpc.cs** - named pipe `CyrFlip_Launcher_Pipe`. Strict `ParseCommand`: only `/launcher-run:{guid}` (guid validated + normalized), `/launcher-settings`, `/exit` are ever commands. Listener = dedicated thread (net48 can't cancel pipe waits reliably; `Dispose` unblocks with a dummy client; 200 ms backoff so a stolen pipe name can't spin). Marshalling to the UI thread is the subscriber's job (`CyrFlipContext` posts via `_ui`). - **LauncherJumpList.cs** - hand-rolled COM `ICustomDestinationList` + `IObjectCollection` + `IShellLinkW` + `IPropertyStore` (PKEY_Title via a minimal PROPVARIANT). Vtable order is load-bearing (same gotcha class as `UiaCaretCom`). `BuildTasks` is pure (unit-tested); `Apply`/`Clear` guarded → shell refusal degrades to "no jump list". - **LauncherTrayMenu.cs** - builds the tray submenu **and** the taskbar button's left-click menu (scenarios → separator → Manage → optional Import) from one `Fill`, so the two lists can never drift apart. It is its own file for one load-bearing reason: `ToolStripItem.Dispose` **removes the item from its owner's collection**, so disposing while enumerating `DropDownItems` throws *"Collection was modified"* - verified on net48, and invisible until the *second* rebuild (the first starts from an empty menu). Copy out → clear → dispose, exactly as the settings window does in every `Reload*Rows`. Being a static seam it is covered directly by `LauncherTrayMenuTests`. - **LauncherScenarioDialog.cs / YtDlpLinkDialog.cs** - content-sized modal dialogs (no pixel geometry). The scenario dialog swaps the type-specific section **out of the control tree** (not `Visible=false`) so `DialogLayoutTests` measures only what is laid out. - **LauncherTaskbarWindow.cs** - the taskbar button: a 1x1, `Opacity=0`, permanently **minimized** form (`ShowInTaskbar`) that exists only while the launcher is on. It rests minimized on purpose - a taskbar click on the *active* window minimizes it, so a window that stayed restored would answer only every second click; the restore raises `WM_SIZE`/`SIZE_RESTORED` (the one notification every shell path ends in - `SC_RESTORE` is not always sent), the menu is shown there and the window minimizes again on close, which also hands focus straight back to the user's editor. The `SIZE_RESTORED` of window creation is ignored (only a restore *out of* the minimized resting state counts). A shell "Close window" is cancelled - the launcher switch owns its lifetime, exactly as the tray icon's. - **LauncherBrand.cs** - OneClickRunner's `app.ico`, embedded (`EmbeddedResource` + `LogicalName=CyrFlip.launcher.ico`, so the single exe still ships alone) and handed out cached/shared - callers must not dispose it. Marks the *feature* (settings page header, taskbar button), never the app. `DrawGlyph` repeats the same shape as line art for the settings tab strip, where a full-colour tile among nine line-drawn tabs would read as a foreign object. - **LauncherLog.cs** - `launcher.log` in the same MSIX-aware folder as `layout.txt`; never logs the yt-dlp link. Like `TranslateLog` and `TextMenuLog` it is a one-line wrapper over **`DiagnosticLog`**, which owns the lock, the folder and the **rotation**: on the session's first write a file over 2 MB is cut to its last 512 KB, starting on a line boundary and behind a marker line saying how much was dropped. Without it these three files grew forever (`context-menu.log` writes a line per menu opening *and* per click). The rewrite is **in place, not via a temp file that replaces it** - replacing means deleting, and a file another process holds open cannot be deleted, which is exactly the case that matters since `SupportBundle` reads these logs to build its archive. - Integration: `CyrFlipContext.RefreshLauncherSurfaces()` rebuilds tray submenu + Jump List + hook snapshot on every change (enable/disable, CRUD, language change); `KeyboardHook.UpdateLauncherHotkeys` mirrors the conversion-table snapshot discipline; the chord conflict check is now **4-way** (case ↔ history ↔ conversion rows ↔ scenarios) via `SettingsForm.ClashingCyrFlipAction` + `CyrFlipContext.LauncherUsing`. ## Companion VS Code extension (`vscode-extension/`) A small TypeScript extension (no native code). The app publishes the layout to a file; the extension reads it and renders the layout marker **exactly at Monaco's caret** via an `after` decoration whose CSS is absolutely-positioned (so it doesn't shift text) and outlined. This is the precise in-editor answer to the UIA-can't-find-the-caret problem in VS Code/Electron. Build with `npm install && npm run compile` (or `npx @vscode/vsce package`). Requires the CyrFlip app to be running. **Its palette is not its own** (see `LayoutStyle.cs`): `src/layout-colors.json` is the shared copy, imported with `resolveJsonModule` so tsc emits it into `out/` and vsce packages it. The file has to live inside the extension root - vsce packages nothing from a parent directory, so a shared file under `assets/` would simply be missing from the published `.vsix`. The extension has **no test runner**, so its half of the deterministic fallback is checked by hand against the `fallbackSamples` pinned in that JSON; the C# side is checked by `LayoutColorsTests`. End-to-end flow: hotkey (KeyboardHook, UI thread) → `OnLayoutConversionHotkeyPressed(id)`/`OnCaseHotkeyPressed` → `RunClipboardOp` spins a background thread → `ClipboardHandler.ConvertLayout` (copy → KeyboardLayoutConverter, or TransliterationEngine for an unresolvable US⇄RU pair → paste) or `ClipboardHandler.FlipCase` (copy → CaseFlipEngine → paste). CursorIndicator updates the tray icon independently on its timer. ## Critical detail: the transliteration table Now a **fallback**, not the main path: the conversion table asks Windows for both layouts' mappings, and this hand-written table is used only for the US ⇄ Russian row when those layouts can't be resolved (the Russian keyboard was never installed). It is still the exact behaviour every pre-table release shipped, so it stays correct. Bidirectional, based on the standard ЙЦУКЕН layout. The mapping used in `TransliterationEngine` (26↔26, aligned): ``` QWERTY: q w e r t y u i o p a s d f g h j k l z x c v b n m ЙЦУКЕН: й ц у к е н г ш щ з ф ы в а п р о л д я ч с м и т ь ``` ⚠️ **Spec discrepancy (§5.1):** the spec lists **28** Cyrillic letters for these **26** keys - it includes `ж` and `э`, which on ЙЦУКЕН are the `;` and `'` keys, *not* letter keys. Pasting the spec's two rows together mis-aligns everything from `z` onward (you'd wrongly get `z→ж`). The engine drops `ж`/`э` to keep the rows aligned (`z→я .. m→ь`) and has a static-ctor length assertion guarding against future drift. Punctuation keys currently pass through; the full ЙЦУКЕН punctuation row (`х ъ ж э б ю ё ..`) can be added later. Map case-insensitively but preserve case; pass through characters with no mapping (punctuation/digits) unchanged. ## Error-handling rules (from spec) - No selection → no-op (skip silently). - Clipboard locked → retry up to **3 times**. - Foreground window changes mid-operation → **cancel** the operation. ## Config Settings are stored in the Windows Registry under `HKCU\Software\CyrFlip`. All values have defaults and are written on first change. On first run with no registry key, the app migrates from a legacy `config.json` if one is present. | Registry value | Type | Default | Description | | --- | --- | --- | --- | | `CaseHotkey` | `REG_SZ` | `Ctrl+Shift+F11` | Case-flip hotkey (user-configurable on the "Горячие клавиши" tab) | | `SettingsTab` | `REG_DWORD` | `0` | Index of the settings tab last left open; the window reopens on it (clamped against the live page count) | | `CursorSize` | `REG_DWORD` | `24` | Size of the I-beam cursor and caret overlay marker (px) | | `EnableCursorChange` | `REG_DWORD` | `0` | 1 = replace the system I-beam with the layout-branded cursor | | `EnableCaretOverlay` | `REG_DWORD` | `1` | 1 = show the layout marker next to the blinking text caret | | `CaretDotMode` | `REG_DWORD` | `0` | 1 = use a solid colour dot instead of EN/RU/UK text in the overlay | | `EnableLanguageSwitch` | `REG_DWORD` | `0` | 1 = after a conversion, also switch the target window to the layout the text now reads in | | `FlipCapsLockAfter` | `REG_DWORD` | `0` | 1 = after a case flip, also toggle the physical CapsLock key | | `KeepSystemAwake` | `REG_DWORD` | `0` | 1 = hold `ES_SYSTEM_REQUIRED` (Windows won't sleep on idle); restored at startup | | `KeepScreenOn` | `REG_DWORD` | `0` | 1 = hold `ES_DISPLAY_REQUIRED` (screen won't blank/lock on idle); restored at startup | | `EnableHotkeys` | `REG_DWORD` | `1` | Master switch; 0 = the hook passes every key through (no hotkey acts) | | `EnableCaseHotkey` | `REG_DWORD` | `1` | 1 = the case-flip chord is active (only while `EnableHotkeys`=1) | | `EnableHistoryHotkey` | `REG_DWORD` | `1` | 1 = the clipboard-history chord is active | | `DeferToRemoteDesktop` | `REG_DWORD` | `0` | 1 = while an RDP client (mstsc/msrdc) is focused, let the chord pass to the remote session | | `EnableContextMenu` | `REG_DWORD` | `0` | 1 = CyrFlip's own menu over the selection is on (the `WH_MOUSE_LL` hook is installed). 0 = no mouse hook at all | | `ContextMenuChord` | `REG_SZ` | `Ctrl+RightClick` | The chord that opens it, as an invariant token; an unparsable value (incl. a bare `RightClick`) falls back to the default | | `LanguageHotkeysBackup` | `REG_SZ` | *(empty)* | One-time JSON snapshot of Windows' own language hotkeys, taken before CyrFlip first edits them; empty = never touched | | `InputLayoutsBackup` | `REG_SZ` | *(empty)* | One-time JSON snapshot of the keyboard-layout stores (Preload/Substitutes + User Profile), taken before CyrFlip first edits them | | `LayoutConversionProfiles` | `REG_SZ` | *(seeded)* | JSON array of the layout→layout conversion table (source KLID, target KLID, chord, enabled). Absent (or empty while the legacy `Hotkey`/`EnableFlipHotkey` values survive) = seed one EN⇄RU row on Ctrl+Shift+F12; `[]` after the migration = the user emptied it, left alone | | `EnableScenarioLauncher` | `REG_DWORD` | `0` | 1 = the scenario launcher is on (tray submenu + taskbar button with its Jump List and left-click menu + per-scenario hotkeys). 0 = every surface cleared, button closed; scenario XMLs stay on disk | | `LauncherFirstEnableDone` | `REG_DWORD` | `0` | One-time marker: the first enable's migration offer / Calculator seeding ran; an emptied list is never re-nagged or reseeded | | `EnableTranslate` | `REG_DWORD` | `0` | 1 = the translator is on (chords bound, tray entry visible). 0 = no chord, no tray entry, no socket | | `TranslateSeeded` | `REG_DWORD` | `0` | One-time marker: the starter row was offered on the first enable; a table the user empties is never refilled | | `TranslateProfiles` | `REG_SZ` | *(empty)* | JSON array of the translation table (target language or the `ui`/`active` token, chord, enabled) | | `TranslateEndpoint` | `REG_SZ` | *(empty)* | Ollama address; empty = `http://localhost:11434`. Another address means the selection is sent to that machine | | `TranslateModel` | `REG_SZ` | `aya-expanse:8b` | Model name; empty = use whichever model is installed. **Chosen by measurement (2026-07-28), not by size:** every model under 4 GB failed English ⇄ Russian ⇄ Ukrainian - `qwen2.5:3b` (the old default) even emitted Chinese characters inside a Russian translation | | `TranslateTimeoutSeconds` | `REG_DWORD` | `120` | Hard timeout for one completion | | `TranslateKeepAliveMinutes` | `REG_DWORD` | `5` | Ollama's `keep_alive`; the RAM is the Ollama process's, not CyrFlip's. 0 = unload at once | | `TranslateAutoStartServer` | `REG_DWORD` | `1` | 1 = try to start Ollama once (waiting ~8 s) when it doesn't answer | | `TranslateCopyResult` | `REG_DWORD` | `0` | 1 = put the translation on the clipboard, where the history records it like any other copy | | `TranslatePasteResult` | `REG_DWORD` | `0` | 1 = paste the translation over the selection (skipped, with a note, if the focus moved) | | `TranslateShowSource` | `REG_DWORD` | `0` | 1 = show the source text above the translation | | `TranslateWindowTimeout` | `REG_DWORD` | `0` | Auto-close the result window after N seconds; 0 = never | | `TranslateWindowWidth` / `Height` | `REG_DWORD` | `700` / `460` | Remembered size of the result window (its position is always at the cursor). A stored `460`/`260` - the first build's default, i.e. "never resized" - is replaced by today's, and any size is clamped to the monitor the pointer is on | | `TranslateWindowOpacity` | `REG_DWORD` | `100` | Opacity of the result window, 30..100 | | `FlipCount` | `REG_DWORD` | `0` | Usage counter; incremented on each successful transliteration | | `CaseFlipCount` | `REG_DWORD` | `0` | Usage counter; incremented on each successful case flip | | `TranslateCount` | `REG_DWORD` | `0` | Usage counter; incremented on each successful translation | Legacy `config.json` (still accepted on first run for migration): ```json { "hotkey": "Ctrl+Shift+F12", "cursorSize": 24 } ``` ## Build, run, test ```powershell dotnet build CyrFlip.sln -c Release # build (clean build is warning-free; see WarningsAsErrors) dotnet test CyrFlip.sln # run xUnit tests .\src\CyrFlip\bin\Release\net48\CyrFlip.exe # run the app (tray icon → right-click → Exit) .\build.ps1 # build + test + stage single exe + deploy to C:\GD\.. dotnet run --project tools/IconGen # regenerate icon/banner assets ``` The `net48` build output *is* the distributable: a single `CyrFlip.exe` plus `config.json`, no runtime to install. (The spec's `dotnet publish .. --self-contained false` line assumed a .NET 6+ target and does not apply to the net48 choice.) Releases are cut by pushing a `vYY.M.D.HHmm` tag (`.github/workflows/release.yml` builds the ZIP + sha256). winget manifests in `winget/` are filled per release (see `winget/README.md`). Version is stamped at build time as `YY.M.D.HHmm` via a `` property in the csproj (verified: e.g. `26.6.11.1649`). ## Testing strategy - **Unit** (`tests/CyrFlip.Tests`, xUnit): `TransliterationEngine` (EN→RU, RU→EN, case, punctuation/digits pass-through, empty/null, bijection), `CaseFlipEngine` (Latin/Cyrillic case inversion, the CapsLock fix, pass-through, self-inverse, empty/null), `Hotkey` (modifier/key parsing, named keys, fallback, case-insensitivity, `CaseDefault`, `SameChord`, `NameForVk`), `LanguageHotkeys` (chord ↔ `MOD_*` mask round trip, the observed `0xC006`/`0x30` record from the registry, chord equality ignoring the side bits, slot allocation incl. a full range, toggle codes), `InputLayouts` (KLID→langid decode, `BuildPreload` device-handle scheme, `GroupByLanguageTag`), `KeepAwake` (mask composition per switch, idempotent Set, `Reset` → bare `ES_CONTINUOUS`; the P/Invoke is swapped for a recorder), and the conversion table's seeding/migration (`ConversionTableSeedTests`: a fresh install gets the EN⇄RU row on Ctrl+Shift+F12, an upgrade carries over the old chord *and* its off state, an already-empty table whose legacy values survive is still seeded, and a table emptied after the migration is never refilled). `DialogLayoutTests` builds both modal dialogs **in each of the 13 languages** and fails when any caption needs more room than its control got - with a companion fact proving the guard can fail, since a walk that inspects nothing passes just as quietly as a clean one. `DefaultLanguageTests` covers the fresh-install language pick (`Localization.MatchLanguage` takes the culture as an argument, so the whole matrix - all 13 languages, regional variants, untranslated languages, the invariant culture - is testable on any machine). The launcher: `LauncherScenarioStoreTests` (XML round trip incl. the `Hotkey` extension, an as-OneClickRunner-writes-it legacy file, corrupt-file isolation, contiguous ordering + move, delete-all-stays-empty, import = fresh Guid, seed-once), `LauncherMigrationTests` (run against the **byte-exact OneClickRunner fixtures** in `tests/CyrFlip.Tests/Fixtures/OneClickRunner/` - Guid preservation, collision → fresh id, `SPECIAL_YTDLP` → yt-dlp type, corrupt skipped by name, and a hash check proving the source directory is never modified), `LauncherExecutionTests` (target validation matrix, PATHEXT resolution, the `.ps1`/`.cmd` quoting shapes verbatim, yt-dlp link rejection, and the command-shape test proving a hostile `&`-bearing link reaches cmd only through the environment variable), `LauncherIpcAndJumpListTests` (strict command parsing - OneClickRunner's own `/run:` prefix is *not* ours, a live pipe round trip on a test-private pipe name that survives an empty connection, `BuildTasks` order + the Manage/Exit tail, no blank icon sources, hook binding snapshot ignores corrupt chords), and `LauncherTrayMenuTests` (**repeated** rebuilds - the first one can never fail, so only the second catches the dispose-while-enumerating bug; plus per-item closure capture, the empty-list shape, the conditional Import item, and the standalone `ContextMenuStrip` the taskbar button drops down, which is rebuilt on *every* click). `LauncherBrandTests` proves the embedded icon is actually reachable: a wrong `LogicalName` throws nothing, it just silently leaves both branded surfaces blank. The translator: `OllamaClientTests` (endpoint normalization, `/api/tags` parsing incl. every unreadable shape, one NDJSON stream stitched back together and reported chunk by chunk, the request body's model/prompt/`keep_alive`, a server error mid-stream, pull progress and the success/error lines - all through a fake `IOllamaTransport`, because **no test may reach a real Ollama**), `TranslationServiceTests` (prompt contents, the 4000-character cut, the echo/empty retry and that a bad retry keeps the first answer, model preference and tag matching, and the whole `TranslateAsync` matrix: healthy server, not installed, auto-start off, start failed, no models, HTTP failure), and `TranslationProfileTests` (the starter row offered once and never after, a row created chordless when the default chord is taken, the stored-table reader incl. id backfill and unreadable JSON, `[ScriptIgnore]` on the computed flag, the `ui`/`active` token resolution, that `AllCodes` really is every language Windows knows rather than the 13 interface ones, and the `ModelPageUrl` shapes - the size tag dropped, a `hf.co/..` name sent to the index). `LayoutColorsTests` is the cross-language guard: the palette the VS Code extension ships must match `LayoutStyle` entry for entry **in both directions**, the fallback constants must agree, and the pinned samples must be exactly what `ColorFor` produces - a drift here is invisible in both builds and shows up only as a wrong colour on someone's screen. `WorldLayoutsTests` additionally pins `CodeForLangId`, the single decode the marker and the settings tables now share (incl. a language Windows cannot name coming back as its hex id, not as `"??"`). The text context menu: `MouseChordTests` (every offered token round-trips, a bare right click is refused while a bare middle click is not, and `Matches` demands *exactly* the configured modifiers - Win never matches), `SelectionProbeTests` (first definite answer wins, a throwing source is skipped rather than fatal, the class gate that keeps `EM_GETSEL` away from non-edit windows, and **`Unknown` stays `Unknown`** - the fail-open rule has its own test because it is the counter-intuitive one) and `TextContextMenuTests` (the full item order, greyed-not-hidden without a selection, everything enabled on `Unknown`, Paste following the clipboard rather than the selection, disabled modules leaving no dangling separator, per-row closure capture, and - as with the tray submenu - **repeated** rebuilds, since the first can never fail). The log bundle: `SupportBundleTests` (the clipboard-history exclusion **and** that its payload is nowhere in the archive - the test the whole file exists for; an absent log skipped rather than fatal; the tail kept with a marker line and starting on a line boundary; a log held open for writing still collected; the total budget dropping from the end and *saying so* in the report; the archive name; retention keeping five) and `MailSenderTests` (every rung against every MAPI code, `MAPI_USER_ABORT` **not** falling through, the RFC 2368 shape, an overlong body trimmed while the subject survives, and that a non-ASCII path never reaches the ANSI API as-is). Long-run stability (2026-07-30): `ClipboardHistoryOrderTests` - the incremental display order proven **equal to the full reference sort it replaced**, by replaying a thousand random add/touch/pin/unpin/delete operations and comparing after every one (the stream is weighted 3:2 towards adds on purpose: with adds and deletes equally likely it is a symmetric random walk that hovers near an empty history and proves almost nothing, and the test asserts every branch actually ran); `DiagnosticLogTests` (a file under the cap untouched, the tail kept behind a marker on a line boundary, the dropped byte count honest, and **a log held open for writing still rotated** - the case that failed the first implementation, which replaced the file instead of rewriting it); `ClipboardBackupTests` (an image or a file selection with no text still counts as content, and a clipboard whose text could not be read does not - restoring an empty string would clear it rather than leave it be). **Verified live on 2026-07-25** (the tech plan's Фаза 3 gate - a build alone never proves this): with one scenario staged, the shell's own `%APPDATA%\Microsoft\Windows\Recent\CustomDestinations\*.customDestinations-ms` came back holding the task title, `/launcher-run:{guid}`, `/launcher-settings`, the exe path and the resolved icon path - i.e. every `IShellLinkW`/`IPropertyStore` slot landed where intended (a wrong vtable slot fails *silently*, so this is the only real proof). A Jump List click on a live instance dispatched over the pipe without creating a second process; with no instance it one-shot launched and left no process; switching the launcher off **deleted** that shell file and a stale task was then received but not executed. Two localization guards in `SettingsLocalizationTests`: every string registered in `Localization` has a non-empty translation in **all 13** languages, every dynamically built string is actually registered, and the live settings window is constructed **in each language** and walked for surviving Cyrillic (a Latin/Arabic/Indic/CJK build showing Cyrillic = an untranslated label; "ЙЦУКЕН" is allowed). The hook, clipboard flip, layout detection, and the live layout install/remove are interop-bound - verify those manually (see below). **Translator verified live on 2026-07-26** against the real Ollama on the dev machine (`qwen2.5:3b`): probe, model listing and a streamed completion all came back through the production code path - RU→EN, EN→RU, DE→UK and a two-letter string that legitimately translates to itself. First call 26 s (the model being loaded), then ~300-400 ms, which is `keep_alive` doing its job. What that check does **not** cover, and still needs a human at the keyboard: the chord itself, the synthesized copy/paste, the popup's placement and focus behaviour, and the clipboard-history interaction (spec §12.2). - **Desktop-driving checks** (`tools/uitest/`, PowerShell): the interop that xUnit cannot reach - `Test-TrayMouse.ps1` (single tray click advances the last active window's layout, double click opens Settings), `Test-SupportBundle.ps1` (waits for the log archive, then checks its contents, truncation markers and retention - whether a real mail client opened with the file attached is the one thing only a human can confirm), `Save-SettingsShots.ps1` (a PNG per settings tab, for layout/localization eyeballing) and **`Test-LongRun.ps1`** - the hours-long watch: GDI/USER handle counts, private bytes and threads sampled to CSV while a throwaway window's layout is switched to drive the icon/cursor/overlay rendering. It **fails on handle growth** (a leak there is invisible in the memory column) and reports private bytes without judging them, since the unbounded history is *meant* to grow. It deliberately never synthesizes a chord to prove the keyboard hook is alive: CyrFlip ignores injected keystrokes by design, so a synthetic `Ctrl+Shift+F10` would prove nothing - it asks a human to press one at the end instead, which is the only real check that Windows has not silently dropped the hook. Deliberately outside `dotnet test`/CI: they move the real mouse and steal focus. `CyrFlip.UiTest.psm1` holds the reusable seams (PerMonitorV2 DPI, MSAA tray-icon lookup, synthesized clicks, per-window HKL, `PrintWindow` screenshots, `GetGuiResources` handle counts, per-window layout switching) - **write new manual checks on top of it instead of hand-rolling P/Invoke in a scratch script**; `tools/uitest/README.md` lists the traps it already absorbs (UIA can't see the tray toolbar under pwsh 7, the settings caption is localized, the tray click is deferred by `DoubleClickTime`). - **Integration** (planned): full hotkey→copy→transliterate→paste path across Notepad, Word, Chrome; live layout switching. - **Manual** (planned): cursor visibility/non-obstruction; edge cases (multiline, selection at boundaries). Memory stability over 1+ hour is no longer on this list - it is `tools/uitest/Test-LongRun.ps1` above. ## Unified Rules & release conventions The **universal** SZA conventions (repository layout, versioning principle, documentation, development discipline, testing, release & distribution, localization, security, support, AI-agent operation) live in one canonical home - reference it, do not restate it here: **Canon:** the **`sza-unified-rules` repo**, installed as the `sza` Claude Code plugin, so the rules and the skills that apply them are in every session without a path to follow: ``` /plugin marketplace add SerZhyAle/sza-unified-rules /plugin install sza@sza-unified-rules ``` Load a skill rather than the whole canon: `sza:release`, `sza:store-publish`, `sza:feature-to-site`, `sza:spec-to-audit`, `sza:adopt-canon`. CyrFlip is **Overlay A - Windows desktop, no-installer variant**, plus a **companion editor extension** on the VS Code Marketplace. The per-project record with full evidence is `rules/contrib/cyrflip.md` in that repo; the adoption stamp is `.sza-canon.json` here. This project **references** the canon (does not mirror it); only the CyrFlip-specific deltas below stay local. The canon moved out of the hub repo on 2026-07-27; the old `..\Unified_Rules` local path is dead - a path no CI job and no second machine could reach. **Overlay facts (this repo):** - **Version shape** - dotted `YY.M.D.HHmm` (e.g. `26.7.22.1712`), stamped at build time by the csproj (`src/CyrFlip/CyrFlip.csproj`). The `v*` git tag is authoritative for release-asset names; `release.yml` re-pins the embedded exe version to the tag (`-p:Version=`) so the in-file stamp matches the ZIP name. The **VS Code extension runs its own semver clock** (`vscode-extension/package.json`, `0.1.1`), decoupled from the app date tag. - **Channels (4 publish ops + a site).** GitHub Release (`CyrFlip--windows-x64.zip` + `.sha256`, body auto from `generate_release_notes`); **winget** `SerZhyAle.CyrFlip` (`winget/*.yaml`, `InstallerType: zip` / `NestedInstallerType: portable`, alias `cyrflip`); **Microsoft Store** MSIX (`msix/build-msix.ps1`, manual Partner Center); **VS Code Marketplace** (`npx @vscode/vsce publish`, manual). GitHub Pages from `/docs` (trilingual). **No installer** - portable ZIP only (no Inno `.iss` / WiX `.wxs`). - **Frozen anchors** - winget `PackageIdentifier: SerZhyAle.CyrFlip`; MSIX Identity `Name: SZA.CyrFlip` + `Publisher: CN=F98ACEDB-1E22-4C39-AF63-F9FCFE807DCD` + Store ID `9NB4W41NGQJ4`; exe/`AssemblyName` `CyrFlip`; VS Code extension id `SerZhyAle.cyrflip-vscode`. No Inno `AppId` / WiX `UpgradeCode`. - **Companion extension** - `vscode-extension/` is **not** an edition and **not** a co-shipped binary: it is a code-independent (TypeScript) companion coupled to the app by a **one-way file contract** (the app writes `layout.txt` via `LayoutPublisher.cs`; the extension polls it), published on its own clock. **Build/release wall & CI cost levers** (every repo runs on paid Windows minutes): - **`build.ps1`** = СБОРКА (free local build, appends `[skip ci]`, may push to `main` but **never tags**); **`release.ps1`** = РЕЛИЗ (the only tagger; preflight = on-main + local build/test, then `-Push` creates the `release: vX` anchor commit + tag). The billable boundary is that tag, not a script-capability wall. **A dirty tree blocks neither script** (single-developer repo): the preflight reports how many paths are uncommitted - they simply won't be in the release, since the tag points at the anchor commit - and carries on; `-RequireClean` brings the old refusal back. `build.ps1 -Commit` with nothing to commit likewise reports and finishes instead of failing. - Levers wired: `[skip ci]` on build commits; `paths-ignore` on `ci.yml` (`**.md`, `docs/**`, `PLAN/**`, `winget/**`, `assets/**`, `vscode-extension/**`); `if:` skip of `release:`-prefixed commits; the tag triggers only `release.yml` (branch-only CI); `concurrency cancel-in-progress` true on CI / false on release. - **Tag-format gate** - `release.ps1` validates `^\d{2}\.\d{1,2}\.\d{1,2}\.\d{4}$` + `ParseExact` before tagging, so a mistyped `-Version` fails before any push. - Releases ship **unsigned** (CI Authenticode step is opt-in via `SIGNING_CERT_*` secrets, which are unset; the Store re-signs the MSIX). Don't claim releases are signed. **Repo-specific decisions (resolved 2026-07-23):** - **Store-listing source of truth = `msix/store-listing-export.csv`** (Partner Center export-then-merge), all 13 languages. `msix/store-listings.md` and `store/listing-*.txt` are **generated** from it by **`msix/render-listing-mirrors.ps1`** - never hand-edit the copy in them; the prose *outside* the three locale sections of the md (the runFullTrust justification, the submission notes) is hand-written and the renderer leaves it alone. `release.ps1` runs the same script with **`-Check`** in the preflight, because the alternative is what actually happened: both mirrors sat a release behind on the translator paragraph and on four feature lines, in three languages, and nothing could see it - the mirror is exactly what gets pasted into the live listing on the day the CSV importer refuses a file. `msix/listing/.txt` is upstream of the CSV, not downstream: the merge fills **empty** cells only, so a change made there alone never reaches the Store - edit both. - **VS Code extension is published by hand** (`vsce publish`, its own semver), only when `vscode-extension/` changed - deliberately no `ext-vscode-v*` tag/workflow. **Local specifics (kept):** - Single-instance via a **path-independent named mutex** (`Local\CyrFlipSingleInstance`), so debug/release/ renamed copies don't each install a hook. - Code style is **wired in the csproj**: `enable` + a fixed `` list (`CS0108;CS0162;CS0164;CS0168;CS0219;CS0414;CS0649;CS1998`). Keep clean builds warning-free. - `build.ps1` deploys the staged exe to the local sync folders `C:\GD\i\` and `C:\GD\tc\SZA\_APP\`. - Interop pieces (hook, clipboard flip, layout detection) still need **manual validation** - run the tray exe and exercise the hotkeys in real apps; xUnit only covers the pure logic.