Skip to content

v1.2.0

Choose a tag to compare

@owenpkent owenpkent released this 16 Aug 19:27

Installing on Windows: about the SmartScreen prompt.
On first launch you may see a blue "Windows protected your PC" screen. This is normal for newer apps and does not mean anything is wrong. Alpha-OSK is digitally signed by OK Studio Inc. Click More info and you will see that publisher name, then click Run anyway to install. The prompt stops appearing on its own as more people install. (Microsoft removed the SmartScreen fast-pass for code-signed apps in 2024, so reputation now builds from download volume, not from the certificate.)


[1.2.0] (2026-08-16)

Headlines: the keyboard now predicts the strings the word model structurally could not hold (numbers, phone numbers, house numbers and email domains, and it offers to save an email, phone number or address it sees you type); the Snippets window is rebuilt as a tile grid where a tap copies to the clipboard and everything else lives behind right-click or the Manage toggle, with a colour tag per snippet; Compact View, a denser 13x4 keyboard for small screens, with its own Number Row panel and a second symbol page; intelligent spacing, so the auto-space after punctuation knows not to fire inside 3.14 or owen@gmail.com; right-click a modifier to lock it held down; and a security pass whose three worst findings were typed text reaching the plaintext diagnostic log, accepting a suggestion pill bypassing privacy mode entirely, and a malicious backup archive being able to plant a command that ran on one tap.

Added

  • The Snippets window is rebuilt: tap to copy, right-click for everything else, and a colour tag per snippet. Requested. Snippets were a vertical list of rows, each carrying a bright blue pencil and a bright red X four pixels from the button you press every day, and the red one deleted your snippet outright with no confirmation and no undo. That was the worst arrangement available for a window operated with an imprecise pointer, and none of those colours came from your theme, so on Typewriter (a light theme) and Blackboard (a pale accent) the three buttons read as foreign objects stapled to the window. The list is now a grid of tiles, two across, six to a page, with nothing on it but snippets. Tapping one copies it to the clipboard and a toast on the keyboard says which one it took. Right-clicking one turns the window into an actions sheet for that snippet: copy, edit, colour, move earlier, move later, delete, each a full-width row you can read rather than an icon you have to decode. If you cannot right-click, the header's Manage button is the way in: tap it and a plain tap on any tile opens that snippet's sheet instead of copying it, tap Done to go back. Press-and-hold is deliberately not a third way, because a click held a beat too long is ordinary on a keyboard built for slow motor input and must never turn copying a snippet into opening a menu. It is a mode rather than a small button on each tile for the same reason the tiles have nothing else on them: a second target on a 165 px tile sits a few pixels from the one you press every day, which is the arrangement this window was rebuilt to get rid of. In manage mode the whole tile is the target and copying is switched off, so the worst a mis-tap can do is open a sheet you can back out of.

    Copying replaced typing, and that is the change to know about. Tapping a snippet used to synthesise the text into whatever app had focus, which is one click against a paste's two and sounds strictly better. It is not, because it only lands correctly when the caret is already in the right field and the app takes synthetic keystrokes cleanly (the entire reason Compatibility Mode exists), a long address arrives one character at a time, and when it goes wrong it goes wrong silently, into whichever window happened to be focused. The clipboard has no focus race. Because a clipboard write is invisible by nature, the toast is not garnish: it is the only evidence anything happened, it names the snippet since colour-tagged near-duplicates are exactly what this window now encourages, and it lives on the keyboard rather than in the snippets window because that window closes itself on the same tap. Copying an empty snippet does nothing at all rather than wiping what you had already put on the clipboard, which is the one way this feature could destroy something.

    Colour tags are the reason the grid works. Six tiles at 165 px each are quicker to scan than six full-width rows only if you can tell them apart, and two email addresses look identical at a glance. Red, amber, green, blue, purple, plus grey for untagged, which is the default and is why there is no grey in the list: a tag that reads as the default is a tag you cannot see. The tag inks a bar down the side of the tile and tints the whole tile, because at that size a stripe alone is easy to miss. Tags are stored as a name from a fixed list and never as the colour itself, since snippets.json is replaced wholesale by a Data Backup import and the stored value ends up driving a colour in the interface; an unrecognised tag falls back to grey rather than being drawn, and the rule is re-applied every time the file loads.

    Everything else in the window was brought along. Deleting always asks first, in place, with Keep as the wider and nearer target. Reordering finally has a way in (it worked in the backend and appeared in no menu), and moving follows the snippet rather than the slot, bringing the grid to the page it landed on. Paging replaced growing downward: this window floats over whatever you are typing into, so a list that kept extending would eventually cover it and at the 50-snippet cap would run off the screen entirely; a short last page keeps its full height so the pager and the Add button never slide up under a pointer already moving toward them. Add goes inert at the cap rather than appearing to work, because the store refuses past 50 and the old button could not tell that from success: it opened the editor on "the last snippet", which at the cap is an existing one you never asked to edit. The window stays where you drag it across restarts, instead of recentring on every launch and undoing the one adjustment anybody makes to it. Every colour now comes from the active theme, with the destructive red picked per theme since a dark red is illegible on Typewriter's cream and a bright one glares on Spaceship's near-black. And no icon in this window is a typeset character any more: the pencil and the cross were font glyphs, which Windows resolves through Segoe UI Emoji, rendering them in colour and ignoring the colour they were given, the same trap the modifier-lock padlock fell into. They are drawn from paths now. Covered by a new tests/test_qml_snippets.py, which drives the real QML headlessly, plus the tag rules in tests/test_snippets.py.

  • The keyboard now suggests numbers, phone numbers and email addresses. Requested. Everything Alpha-OSK had ever learned was a word, because the prediction engine splits your typing on [a-zA-Z']+ and throws away every digit and symbol before it stores anything. Your phone number, your zip, your house number and your email address were therefore in a blind spot the engine could not even represent, and they are exactly the strings you retype most and the most expensive ones to type with a pointer: ten digits is ten clicks, and on Compact View the digits sit behind a layer hop. Worse, typing a digit used to blank the suggestion bar outright, so the moment you started a number the keyboard stopped helping at all. Those strings are now kept whole in a separate store, matched by prefix, and offered back the same way words are: type "12" and a house number you have used before completes it, type the first digits of your phone number and the rest is one tap. Accepting one appends a space when the token sits mid-sentence (a house number is followed by a street), and deliberately does not after an email or a phone number, because those are field values and a login form that does not trim its input rejects a trailing space with a validation error you then have to notice and undo.

    The store is deliberately dumber than the word model in two ways. It has no notion of context, because "this is the number I always type" is already carried by how often you typed it. And it does no fuzzy correction, because correcting a mistyped letter is a favour while "correcting" a digit silently changes a number, and a phone number that is one digit out is worse than no suggestion at all.

    What it will not remember matters more than what it will, since anything stored here is offered back in the suggestion bar and travels in a Data Backup export. Any run of more than eight digits that is not shaped like a phone number is refused outright, which puts card numbers, account numbers and a US Social Security number (in every one of its written forms) out of reach by construction. That bar is blunt on purpose: enumerating which kinds of long number are sensitive is a game you eventually lose, so the whole category is declined and the occasional legitimate miss accepted. Phone numbers themselves are admitted by the same digit-grouping rule the snippet detector already uses, so 123-45-6789 is not mistaken for one. The rule is re-applied every time your model loads, so tightening it later cleans out an existing store rather than grandfathering things in. Nothing learned here reaches the log, and accepting one counts toward keystrokes-saved but is kept out of the dashboard's "Top Words" and out of analytics.json. Your phone number should not turn up on a screen you might share. Off entirely while learning is paused, and cleared by Clear Learned Data along with your words. docs/PRIVACY.md has a section on it.

  • Typing @ offers common email domains. gmail.com, outlook.com, yahoo.com and the rest appear as soon as the @ lands, so an address is a local part plus one tap instead of a further fifteen keystrokes. Domains you have actually typed move ahead of the built-in list as you use them, which is what makes a work address on a company domain overtake gmail.com after a few sends. The pills show the domain alone rather than the whole address, because at twenty-odd characters a pill the bar would drop to two suggestions (it drops low-ranked pills rather than eliding them), and the local part is already on screen an inch away. @owen is left alone and still gets ordinary word suggestions: that is a mention, and a name is something the word model can genuinely complete.

  • Alpha-OSK offers to save an email, phone number or address it sees you type. Type your email once and a small banner appears above the keyboard: "Save this email to Snippets?" with the detected value shown, one large Save button and a dismiss ✕. Tapping Save drops it into the matching Snippets slot, so from then on it is one tap instead of thirty keystrokes. New toggle in Settings → Data & Privacy → Privacy, on by default. It sits under Privacy rather than Smart Typing because the question it raises is "may the keyboard notice personal details you type", not "how should typing behave". Everything about it is deliberately timid, because the failure it can cause is offering to store personal data nobody asked it to store: it never runs in privacy mode (a password field cannot produce an offer, let alone a saved copy), never offers the same value twice, never offers something already in a snippet, never stacks two offers, and writes nothing at all until Save is tapped. Detection is conservative for the same reason, since an offer you have to dismiss twice is an offer you learn to ignore. A phone number is matched on how its digits are grouped rather than merely how many there are, against a short allow-list of the shapes people actually write phone numbers in, because a count-only rule accepts a Social Security number, an ISO date, the leading digits of a card and an IP address, and offering to store an SSN typed into a tax form is the worst thing this feature could do. An address needs three anchors, not two: a house number, a capitalised street-name word, and a street-type suffix. Two were not enough, because the suffix list is full of ordinary English words and any digit run reads as a house number, so "it took 2 hours to drive" and "we walked 3 miles down the road" both matched. The capital is what separates an address from prose; the cost is missing an address typed entirely in lowercase, which is the right way round, since a missed offer is one you never see and a false one puts a slice of a private message on screen and one tap from disk. If a slot already holds a different value it is never overwritten, and a second address becomes "Email 2" instead, because work and personal are both worth keeping. The banner stays up for 8 s rather than the 1.4 s of the existing confirmation toasts, since it has to be read, decided on, and clicked with an imprecise pointer.

  • Intelligent spacing: the auto-space after punctuation now knows when not to fire. Reported as auto-space making no sense while typing a website, an email or a number, which it did: "3." became "3. " mid-decimal, "1," broke a thousands separator, and every dot in a domain got a space. New toggle in Settings → Smart Typing → Suggestions, on by default, greyed out when Auto-Space After Punctuation is off since it only modifies that behaviour. The space is now skipped inside a recognisable structure: decimals and thousands separators, times, version strings, IP addresses, file paths, URL schemes, "www.", anything after an "@", and every dot after the first in a dotted run. A bare run of digits is the one case that cannot be settled when the punctuation lands: "3" and "42" are the same token, so "3." might be a decimal and "42." might be the end of a sentence. Rather than guess, the space is withheld and settled by the next character: a digit confirms the decimal, a letter proves it was prose and the space (plus any capital it owed) is typed then, one keystroke late. That is the opposite direction from the rejected rule below, because it only ever adds a space, so a wrong guess costs a late space rather than mangled text. The structural rules are also scoped to the punctuation that can be part of a structure: a domain contains dots and colons and never a comma, so "owen@gmail.com, thanks" keeps its space. One limitation is documented rather than hidden: a bare "example.com" still gets a space after its first dot, and because that space ends the run, "example.co.uk" loses one per dot. At that instant the word is just "example", and the only thing separating it from "I went home." is text that has not been typed yet. Two ways of guessing were tried and both rejected. Reading the next character turns "i went home. then i left" into "home.then". And a rule that joined a lowercase first word when nothing with a space had been typed yet (meant to catch a URL bar) quietly ate the first sentence of every newly focused text box, because the buffer it read is cleared whenever focus moves: clicking into a field and typing "hello." lost the space, then kept losing it. Corrupting ordinary writing is a far worse failure than one space you can delete, so every remaining rule requires positive evidence in the word itself.

  • Two-letter words are suggested again. Reported. The next-word filter threw away every word of one or two letters except "I", which meant it discarded the ones next-word prediction is actually best at: after "I want" the useful suggestions are "to", "it", "my", "us"; after "one" they are "of" and "or". Those are also the most common words in English, so the bar was quietly withholding its strongest guesses and offering its fourth-best instead. It is now an allow-list of real short words rather than a length rule, which matters because the keyboard learns whatever you type: two-character fragments left behind by a typo or an interrupted word live in the model too, and simply relaxing the length would have let all of them compete for a pill.

  • Moving the caret inside a text box now clears the prediction context. Reported as switching between areas of a window not clearing suggestions. Alpha-OSK already noticed you switching apps, and noticed the caret moving between two separate controls, but it could not see a move within one control: clicking from one paragraph to another in the same text box left the suggestions built from wherever you were before. The same blind spot covered a common web case, where a whole page is reported as a single element so two fields on it look identical. It now watches the caret position itself. Two guards keep it quiet: typing moves the caret constantly, so a move is only counted when you did not just type, and it never fires in the middle of a word, both because a stale context matters least there and because scrolling drags the caret across the screen without moving it in the text. Windows only for now, and apps that publish no caret (most browsers, most Electron apps) are simply unaffected rather than misled.

  • Clicking into a different field now clears the prediction context in browsers too. Reported as the keyboard still keeping prediction context inside a window. The previous three signals all answer the question through the accessibility layer, and in a browser they go quiet together: a whole page is commonly reported as one element, so moving between two fields on it looks like no movement at all, and the same page publishes no caret for the fix above to read. Both fail closed deliberately, since an unreadable signal must never be the reason your typing context is thrown away, and the result was that the single most common way to move (clicking the next box on a form) was the one case nothing caught. So there is now a fourth signal that needs no cooperation from the app: the click itself. Twenty times a second Alpha-OSK asks whether the left mouse button went down and which program owns the window under the pointer; if that program is not Alpha-OSK, the caret has moved and the context goes. Clicks on the keyboard are recognised by process rather than by position, which covers the keys, the pills, the title bar, the snippets window and every popup in one check, so typing can never clear its own context. This is coarser than the signals it backs up, because clicking a toolbar button or a scrollbar does not move the caret and costs you the next-word suggestion you would have had. That is the deliberate trade: a suggestion that describes the box you just left will insert the wrong text into the box you are now in, which is the failure worth spending a suggestion to avoid. It carries the same guard as the caret fix and never fires in the middle of a word, since clearing a partial word that is still on screen is what makes the next pill tap duplicate it. Windows only; Linux and macOS still notice application switches but not moves inside one window.

  • Shift capitalizes the suggestion pills. Caps Lock already flipped the pills to match; Shift did not, so the only way to get a capitalised suggestion was to type its first letter shifted and wait for the bar to catch up, which rather defeats the point of tapping a pill. Tap Shift with suggestions showing and they capitalise immediately; tap one and the capitalised word is what lands. Shift is consumed by the tap the same way it is by typing a letter, and a right-click-locked Shift stays locked. Caps Lock still wins over Shift, and an uppercase you already typed still wins over both, so "iP" continues to give "iPhone": a capital already in the prefix says something more specific about a word's shape than a pending Shift does.

  • Compact View: a denser keyboard for small screens. New toggle in Settings → Appearance → Panels, off by default. The full-size layout is a faithful replica of a 104-key physical keyboard, and most of what makes it wide exists to serve ten fingers resting on a home row, which is not how an on-screen keyboard driven by a single pointer is ever used. Because Main.qml derives the key size from the widest row and centres every narrower row, all that surplus width became symmetric dead space on the sides: 26% of the keyboard's width on the space row alone (122 px per side at the default 940 px window), and it scaled proportionally so it was just as bad on a small screen. Compact View is a uniform 13-column by 4-row grid in which every row totals exactly 13.0 units, so the gutters vanish by construction and there is no stretching logic anywhere in the QML. Measured at identical key size that is 33% less area, a 16% narrower window and 20% less height; at a 940 px window the keys instead grow from 58 px to 69 px (+20%). Letter positions are deliberately untouched: frequency-weighted pointer-travel measurements across four candidate arrangements all landed within 8% of each other (QWERTY adjacency dominates and every variant preserves it), so rearranging the alphabet costs real fluency and saves nothing measurable. The savings come entirely from deleting what a pointer cannot use: the duplicate right-hand Shift / Ctrl / Alt (266 px), the 6-unit space bar, and the dedicated number row. Arrows, Enter, Backspace, Delete, Home, End, PgUp, PgDn and / all stay on the base layer, with Enter and Backspace kept double-width; digits and symbols move to a ?123 layer, and right-click still types the shifted variant so / gives ?, , gives < and . gives > without a layer hop. Esc is the one key that sits on ?123 rather than the base layer: a 13-unit row has no spare unit, so Del and Esc trade places, and forward-delete is far more common than Escape in text entry. (The Number Row panel below puts an Esc back at the top-left, so it is not actually behind the hop in practice.) The bottom row and the right-hand navigation column are identical on both layers, so nothing reached for constantly moves under the pointer when switching. Compact is orthogonal to the letter arrangement (currentLayout stays qwerty / dvorak / colemak) and a layout with no -compact variant falls back to full size, so the toggle is always safe. Implemented as data/layouts/qwerty-compact.json plus QML only: layers are a QML-side view concept, so neither the Python backend nor the C++ rewrite needed any change and the feature works on both. New tests/test_layouts.py enforces the structural invariants for every layout file (equal row widths, reachable layers, known key actions, modifier stateKeys), and new tests/test_qml_compact_view.py loads the real qml/Main.qml headlessly under the offscreen platform plugin to assert the layer switching and derived sizing behave, since a QML binding error is a runtime warning that would otherwise ship as a blank keyboard. Full design and measurements in docs/architecture/COMPACT_VIEW.md.

  • Right-click a modifier key to lock it held down. Right-clicking Shift, Ctrl, Alt, or Win now locks it in a held state: it draws a solid bar along its bottom edge and stays held across many keystrokes instead of auto-releasing after one. This is the mouse-driven answer to "hold the key down" · you can fire Ctrl+C then Ctrl+V without re-tapping, hold Shift to drag-select a whole region, or Ctrl+click links repeatedly. Right-click it again, or plain left-click it, to release. Left-click is unchanged (the existing sticky one-shot), and Caps Lock is unaffected since it's already a persistent toggle. Under the hood the modifier is genuinely held at the OS level, and a subtle bug was fixed along the way: send_key and replace_text used to wrap each keystroke with a modifier press+release, and that trailing release silently dropped a held modifier after the first key (so Ctrl+click / Shift+drag / Alt+Tab broke even though the key still showed held). They now skip wrapping a modifier that is already physically held and rely on the standing hold, the same guard send_text already used for Shift. Ported to both the Python backend and the C++ rewrite (shared QML gets the lockBar on KeyButton).

    The lock indicator went through two designs. The first was a hardcoded gold 2 px ring plus a 15x15 gold badge holding a 9 px 🔒, which in practice rendered as a yellow blob in the corner: at that size the padlock is a smudge, and Windows draws it through Segoe UI Emoji as a colour glyph, so the badge's text colour was ignored outright. The gold was also fixed, so it fought all nine themes. Because a locked modifier already implies the active state, the key carries the accent fill regardless, and what the indicator actually has to say is "more than a one-shot" rather than "a different thing", so it is now a solid 3 px bar along the bottom edge, sitting on that same accent fill. The bar is inked with the same luminance rule that picks the key label's colour on an active fill (dark on a bright accent, white on a dark one), which was factored out into a shared _onFillColor property so the label and the bar cannot drift apart; that keeps it legible on the pale-accent themes (Blackboard, Spaceship) and on Typewriter, which is light outright. No emoji anywhere on a keycap now: any glyph small enough to fit is at the mercy of the host emoji font. Coverage in tests/test_keyboard_bridge.py::TestModifierLock and the send_key / replace_text held-modifier regressions in tests/test_platform.py. Full design in CLAUDE.md § Sticky Modifiers → Right-Click to Lock.

  • A Delete key, on every layout. The full-size layouts had no forward-delete anywhere on the main keyboard: it existed only in the optional Navigation panel and the Numpad's NumLock-off mode, so with those panels hidden the only way to remove a character ahead of the caret was to walk the caret past it with the arrow keys, backspace, and walk back. Del now sits at the right end of the QWERTY row on qwerty / dvorak / colemak. That row was 14.3 units against the number row's 15.5, so the key is free: the widest row is unchanged and the default 940 px window does not grow. In Compact View, Del takes the base-layer slot Esc used to hold (see the Compact View entry above for why the two had to trade). A new structural test asserts every layout file carries a delete action on its entry layer, so a future layout cannot quietly ship without one.

  • Number Row panel. A standalone Esc 1-0 - = strip above the keyboard, shown automatically in Compact View, whose digits otherwise sit behind the ?123 hop. Thirteen 1-unit keys means it is exactly the compact grid's 13.0 units and lines up flush with no side gutters. The digits behave like any other character key: Shift shows and types the shifted glyph, right-click types the shifted variant without flipping sticky Shift, both flash the key-preview bubble, and every digit registers in the swipe hit-test registry so taps are not swallowed while swipe typing is on. It is a panel rather than a fifth compact row because both compact layers must stay four rows of 13 units, and because a panel is independent of which letter arrangement is selected.

    There is no toggle for it, and that is deliberate: digits are not an optional part of a keyboard. Visibility is derived from whether the active layout JSON already carries a number row of its own, which the full-size layouts do and the compact variants do not, so the panel fills in for exactly the layouts that are missing one. Digits are therefore always on screen in both views, and a full-size layout can never end up with this narrower centred strip stacked on the number row already built into it. The check reads the layout rather than the Compact View flag because a letter arrangement with no -compact variant silently falls back to full size.

    The leading slot is Esc rather than the physical keyboard's `. Compact traded Esc onto the ?123 layer to make room for Del (see the Compact View entry above), which put the single most common "get me out of this dialog" key behind a layer hop; this row restores it at the top-left corner where a real keyboard keeps it. Nothing is lost, since `/~ stays on ?123 row 2 and the full-size layouts carry their own in the layout JSON. The duplicate Esc is deliberate: ?123 stays the fallback for any future layout that shows the compact grid without this panel. Because Esc is a special key rather than a character key it takes no key-preview bubble (a bubble over Esc isn't "what it typed", matching the main grid) and it is deliberately kept out of the swipe key-centre registry, where a phantom "Esc" centre would corrupt every shape match.

  • Tab now clears the prediction context. Requested. Tab means "next field", so everything the suggestion bar was built from belongs to a field the caret has left: tapping a pill after tabbing inserted the tail of a word whose head was in the previous box. It is the same failure the click-detection above exists for, arriving through the keyboard instead of the mouse, and it is the only one of the five signals that works identically on every platform, since it needs nothing from the accessibility layer. The word in progress is deliberately not learned on the way out: Tab is the accept-completion key in every IDE and shell, where the partial word is a prefix the app is about to finish, so learning it would teach the model "hel" every time you completed "hello". A pending offer to save an email or address survives, because tabbing to the next field of a form is the likeliest thing to happen while that banner is up.

  • The clear-context button's icon is now drawn rather than typeset. Reported as looking off-centre, which it was. The button rendered the character "⟲" centred as text, and centring text centres the box, not the mark inside it: fonts do not promise the ink sits in the middle of the box, and this one puts the ring low and to the right with its tail hanging out to the left. The result leaned visibly. It is now Feather's rotate-ccw, drawn from that project's own published outline, so what is on screen is a designer's icon rather than a font's idea of one, and it is positioned by where its ink actually falls rather than by a box you cannot see. The icon is fed to the canvas that was already there, so nothing new has to be bundled into the installer, which matters because a missing drawing module would not degrade quietly, it would fail the whole keyboard. Feather is MIT licensed; the notice is in the new THIRD_PARTY_NOTICES.md.

  • A dedicated : key on the Compact View ?123 layer. Row 2 of that layer already carried ; with : as its shifted variant, but a shifted variant is invisible: the keycap reads ; and nothing on screen says a colon is one right-click away, so in practice the layer read as having no colon at all. Row 3 exists to surface exactly those glyphs as keys in their own right (! @ # $ % & ( )) and was already one short of the full set for the same 13-unit reason, so ^ — the rarest of them in prose — gives up its slot. ^ is unchanged as the shifted variant of 6 on row 1.

Changed

  • Compact View now turns the Navigation and Numpad panels off, and disables their toggles while it is on. Reported as the Appearance menu getting confusing. The three were independent, so you could pick the small-screen mode and then, one toggle later, silently undo it: the two side panels cost roughly 470 px of window width, which is precisely what Compact View exists to hand back. Compact now wins. Both panels are forced off when it is enabled, and their toggles render greyed with a one-line reason under the label rather than disappearing, so the setting reads as unavailable rather than missing. Compact View also moved to the top of the Panels section, above the two toggles it gates, so the cause sits above the effect. The forced-off state is suspended, not saved: the change handlers skip their settings write while compact is on, so leaving compact restores whatever panels the user actually had, and quitting in compact does not resurrect them on the next launch. Guarded by tests/test_qml_compact_view.py::TestCompactViewForbidsTheSidePanels, including the round-trip restore.

  • Tighter key spacing. Reported as too much dead space between the keys. The visible gap between two keycaps is the layout's keySpacing plus twice the inset each KeyButton leaves around its own background, and the inset was the larger of the two at 2 px per side. It is now 1 px, and the keySpacing coefficient drops from 0.0025 to 0.0018 of the window width, taking the gap from 8 px to 5 px at a ~1700 px window. The reclaimed pixels go back into the keys: the inset gives 2 px directly and the smaller gaps shrink the fixed-pixel overhead the sizer subtracts before dividing, so every keycap grows in both dimensions. Bigger targets on a mouse-driven keyboard, which is the right direction anyway. The Navigation and Numpad panels follow automatically, since they take keySpacing from the same property.

  • Compact View: the navigation column now reads Home, PgUp, PgDn, End from top to bottom. Requested. It was PgUp, PgDn, Home, End, which grouped the pair of paging keys together but left the two jump-to-end keys split across the column with nothing to explain the order. Top to bottom it is now a scroll ladder: jump to the top, page up, page down, jump to the bottom. The column is identical on all three layers, as before, and the order is pinned by a test rather than left to whoever next edits the row.

  • Suggestion pills are never truncated: the bar drops low-ranked pills instead. Reported with a screenshot of eight documentation-family candidates in a ~940 px window, every one of them rendered as docu…. Eight identical unreadable pills is worse than no suggestions at all, because there is nothing to choose between them. The previous max-min fair allocator handled "one long word among short ones" correctly but had an even-split fallback for the case where several long words compete, and that fallback is what elided everything. The bar now compresses pill padding first (down to max(14, predHorizontalPad * 0.45)) and then drops candidates from the tail, which are the lowest-ranked ones, until the survivors fit at full text width; leftover space is handed back as padding max-min fair, so a short word still cannot hog room a long one needs. Measured on the reported case at a 1160 px window: four whole words instead of eight elided ones. The only remaining elide is a single word wider than the entire bar, where there is nothing left to drop; it is clamped to the available width and the hover tooltip reveals it. Consequence worth knowing: raising the max-suggestion-count setting past what the window can hold no longer adds visible pills, so the lever for seeing more suggestions is a wider window (or Compact View, whose smaller keys shrink the pill font too). Guarded by tests/test_qml_prediction_bar.py::TestNoPillIsEverTruncated, which asserts on Qt's own Text.truncated flag, the same one the tooltip is gated on, so the test cannot disagree with what is on screen.

  • The title-bar privacy control is now a switch. It has been through three designs: a play/pause icon (read as "is something playing" rather than "is the keyboard learning"), then a text label that swapped between "Learning" and "Paused", which still had to be read and was ambiguous about whether it named the current state or the action a click would take. It is now a proper toggle: a static "Learning" label naming the thing being switched, plus a track whose knob slides right with an accent fill for on and left with a red fill for paused, with the red border and dark red background kept on the whole control while paused. State reads by shape at a glance, and nothing depends on decoding it, since the suggestion bar independently spells out "Learning paused" the whole time it is off.

  • Hold-to-repeat now covers Delete and the arrow keys on the main keyboard, not just Backspace. The layout-driven key delegate hardcoded backspace as the only repeat-worthy action, which was accurate when the main grid held no other repeatable key. Compact View put the arrows, PgUp and PgDn into the main grid, where they silently did not repeat even though the identical keys in the Navigation panel always have. The delegate now checks a shared repeatableActions list (backspace, delete, the four arrows, pageup, pagedown), matching the Navigation panel exactly. Character keys still never repeat, so a slow click cannot produce two letters.

  • The clear-context button (⟲) moved out of the title bar and into the suggestion bar, and is now much bigger. It used to be a small 28x24 icon wedged between the Snippets and Settings buttons in the title bar, which made it a hard target. It now sits at the right end of the prediction-pill row as a round button sized to the full pill height, so it scales up when you make the keyboard larger. Behaviour is unchanged: a click wipes the prediction context (the current word, the sentence buffer, and the sliding 200-character context) and flashes the "Context cleared" toast, for the times an app or browser-tab switch left stale suggestions behind. The pill row reserves the button's strip at the right edge, so pills are sized and centred in the space left over and can never slide under it. (The first cut of this declared the reserve but never subtracted it from anything, and the right-hand pill rendered underneath the ⟲; both halves of the fix, the width subtraction and the explicit row position, are now covered by tests/test_qml_prediction_bar.py::TestClearButtonNeverCoversPills.) The button lives in the shared qml/Main.qml, so the change lands in both the Python app and the in-progress C++ rewrite.

  • Clicking the system tray icon minimizes the keyboard, clicking it again brings it back, and neither lags. Two problems, one gesture. Originally a double click called showMinimized() while a single click toggled show/hide, and because the two gestures had to be told apart, the single click sat behind a timer for the full system double-click interval (typically 500 ms) before anything happened; a tray click that lags half a second reads as broken. Both gestures now do the same thing, immediately. What that thing is has also changed: the toggle used to hide the window outright, and hiding is the wrong stash for this window in particular. The OSK carries a normal taskbar entry (it deliberately drops WS_EX_TOOLWINDOW, precisely so the title-bar minus button has somewhere to go), and hiding threw that entry away, so the keyboard vanished from the taskbar and the tray icon became the only route back. Minimizing parks it somewhere the taskbar and the tray icon agree about, and matches what the minus button already did. Restoring keys off the window's actual state rather than off what the last tray click did, so a keyboard you minimized from the title bar or the taskbar also comes back on a tray click instead of being minimized again into a dead-looking icon. Two cases still hide, both because there is nothing to minimize into and minimizing would be the branch that strands you: macOS, which has no Dock or taskbar entry under the Accessory activation policy, and an X11 window parked off-screen with Tuck away, which is DOCK-typed while parked and has both lost its taskbar entry and made showMinimized() inert. Windows still delivers one double click as Trigger, DoubleClick, Trigger, which would toggle three times and land back where it started, so activations inside one double-click interval collapse into a single toggle; the window is measured from the last toggle rather than the last event, so a stream of clicks can't keep re-arming the guard and leave the icon dead. Right-click (Show / Minimize, Quit) is otherwise unchanged. Both the toggle and the click routing live at module level rather than in a closure inside main(), taking the window, clock, interval and platform name as arguments, so tests/test_tray_icon.py can drive every branch and every gesture sequence without a QApplication.

Fixed

  • The Windows installer carried no version resource, which would have made this the last release your copy could ever auto-update from. The updater in this release reads a downloaded installer's own embedded FileVersion and refuses to run it unless that matches the release it asked for, so that someone able to rename or re-upload a release asset, without being able to forge a signature, cannot roll users back onto an older signed build. The NSIS script never declared a version resource, so the installer reported an empty string, and an empty string fails that check exactly the way a forgery does. Nothing would have gone wrong on the way in, because the 1.1.0 updater predates the check, so the upgrade to 1.2.0 is unaffected; every update after it would have downloaded, verified, refused and stopped, with one line in a log file as the only explanation and a manual reinstall as the only way forward. Found by checking the artefact rather than the build log, which reported a clean signed installer and was right about the part it was looking at. The installer now carries the full version resource, and the build itself fails when the artefact it just produced does not embed the version being built, since a defect that only appears one release later is not one to leave to inspection.
  • The test suite no longer strands update-relauncher processes, each holding an empty console window. Reported as blank terminals opening on git push, which is what they looked like: they appeared around a push, were titled with the repo path, and were empty. They were neither the push nor the build tooling. download_and_install ends by launching python -m src.keyboard_app --update-relauncher as a detached process, deliberately, so it outlives the app it replaces; several tests drive that function far enough to reach it while stubbing only the installer launch, so every run of tests/test_updater.py left four behind. They never clean up either, because the helper waits on a parent PID that is gone the moment the pytest worker exits and has no exit path for that, so they accumulated across runs. Three parts to the fix: an autouse guard in tests/conftest.py so that "no test spawns a real OS process" holds for tests not yet written, CREATE_NO_WINDOW on the spawn itself (DETACHED_PROCESS does not propagate through the venv interpreter's re-exec, so the re-exec was allocating the console), and the remaining half, a helper that exits when its parent is already gone, recorded in TODO.md. Worth knowing for next time: a blank console is easy to misattribute to whatever you last ran, so enumerate conhost.exe and its parent before believing the obvious suspect.
  • check.py no longer pops blank console windows. Reported as two empty PowerShell windows on git push, and they were: every step launches a console-subsystem binary (python -m ruff and friends) without capturing its output so it streams to the terminal, which is right whenever a terminal exists. Run from the new pre-push hook there may be no console at all, since Git for Windows executes hooks through sh.exe and a push started from a GUI owns no console anywhere in the chain, so Windows allocated a fresh one per child. The two ruff steps are the fastest, so their windows are the ones that appear and vanish while the slower steps are still running. The children now get CREATE_NO_WINDOW when, and only when, there is genuinely no console to inherit. That condition is two checks rather than the obvious one: GetConsoleWindow is the direct question but returns 0 for a process on a pseudo-console or with its output on a pipe, both of which happen in ordinary terminals, so it also requires stdout to be a non-tty. The hand-run path is therefore unreachable by construction and keeps its colour. Same defect class as the sites listed in docs/architecture/GOTCHAS.md; this one was previously exempt on the grounds that its output is meant to be visible, which was true right up until a hook started running it.
  • The keyboard starts about half a second faster, because it was building its spell index twice. HybridPredictor.__init__ injected word frequencies into the fuzzy recogniser once before loading the training corpus and again after it. The first injection was entirely discarded four lines later, since the corpus expands the very counts being injected and nothing in between reads the recogniser. It was not a free mistake: injecting frequencies ends by walking every dictionary word to precompute its deletion variants, so a complete SymSpell index was built and thrown away on every single launch. Found while investigating why the test suite had grown so slow, where the same waste was paid ~1300 times over.
  • A held modifier no longer corrupts an inserted word, snippet or swipe. Found while building the Shift-capitalizes-suggestions change above, which made the worst case routine. Tapping a suggestion, a snippet or finishing a swipe types the whole string in one go, and a modifier standing held at the OS level rewrote all of it: with Shift down "Hello" arrived as "HELLO", and with Ctrl down every character arrived as a keyboard shortcut instead of as text. The synthesizer's existing guard only knew not to add a redundant Shift press; it could not cancel a hold that was already there. Reachable in ordinary use rather than only mid-chord, since both a sticky Shift and a right-click-locked one survive a pill tap. All four verbatim-insert paths (suggestion, snippet, swipe, autocorrect retype) now drop the held modifiers for the duration of the insert and put them back afterwards, so the text lands as written and a locked modifier is still locked when it does. They also consume the sticky modifiers the way any other keystroke does, which the codebase's own notes said they should and which none of them actually did.
  • "Auto-Capitalize After Punctuation" never capitalized anything. The setting turned Shift on when a sentence-ending period was typed, and the auto-release block at the end of that same keystroke turned it straight back off, so the next letter came out lowercase and the toggle had no observable effect at all. It now works, and it no longer goes through Shift to do it. Expressing "capitalise the next letter" as a held Shift looks equivalent and is not: chords are built from that same flag, so keeping the Shift alive long enough to reach the next letter also turned Enter into Shift+Enter (a newline in Slack instead of send), Ctrl+C into Ctrl+Shift+C, and the arrow keys into a selection that would not stop extending. It was also the one place in the app that claimed a modifier was held without actually holding it at the OS, which a later feature would have relied on. Auto-capitalize now has its own flag that capitalises the next character, updates the keycaps, and touches nothing else.
  • Compact View: the second symbol page had a bullet where every other layer has a period. Found in review. The =\< page put in the bottom-row slot that the base and ?123 layers both give to ., so hopping to the second page and tapping the position your finger knows as the period typed a bullet, and . was unreachable anywhere on that page. Worse than a mis-typed character, because the bridge treats as an ordinary word character: it is neither sentence-ending nor a word boundary, so it was appended to the in-progress word, no sentence learning fired, and the contaminated prefix went on to break pill insertion. This directly contradicted the invariant the same release notes claimed ("the bottom row and right-hand navigation column stay identical throughout") and the design rule in docs/architecture/COMPACT_VIEW.md. The period is restored and the bullet moved to the pilcrow's slot on row 3. The two tests meant to guard this were the reason it shipped green: both named the base/?123 pair literally, so adding a third layer added zero assertions. They now derive the layer list from the file, which covers any page added later for free.
  • Any symbol the keyboard can type is now treated as a word boundary. Found in review. The check was a hardcoded list of separators, which meant it failed open: the second symbol page added eighteen glyphs (° × ÷ ± ≈ ≠ ≤ ≥ € £ ¥ ¢ § ¶ © ® ™ •) and not one of them was on the list, so typing cost€ left the prediction engine tracking cost€ as the in-progress word. The next letter predicted against that prefix, the next space learned it as a vocabulary entry, and clicking a suggestion failed the case-sensitive prefix match and fell through to the replace path, which backspaced the whole thing off the screen: exactly the word1-word2 failure the separator list was introduced to fix in the first place. The rule is now stated as "everything that is not a word character" (letters, digits, apostrophe for contractions, underscore for snake_case), so a future page of glyphs is covered by construction. The regression test scrapes every glyph out of data/layouts/*.json rather than restating a list, for the same reason.
  • Dvorak could not type [ or ] at all. Found while making a duplicate-glyph test non-vacuous. dvorak.json had copied QWERTY's - and = onto the number row, but real Dvorak puts [ and ] there and keeps -/= on the home and top rows, so those two keys were each rendered twice and the brackets had no key anywhere on the layout. The test that should have caught it was passing because its layer helper skipped any row without a layer field, which is every row in all three full-size layouts, so it iterated nothing: the parametrize ids advertised coverage of four layouts while only the compact one was checked.
  • Compact View: the Navigation and Numpad panels used the same whole-pixel-rounding layout the Number Row was just fixed for. Both were left on GridLayout when that rule was established, and every case in the new panel test switched them off, so nothing measured them. Main.qml reserves an exact float unit budget for each panel when it derives the window's minimum width, so three or four columns each rounding up costs pixels the window was never given. Navigation is now a plain Grid and Numpad a plain Column of Rows (its two wide keys already carry their spanned width, so they needed no column spans). The new test measures both against their reserved budget and was verified to fail by 30 px against the old code.
  • Compact View: the layer switch expressed "release Shift" as a toggle. It called if (root.shiftOn) keyboard.toggleShift(), which made the correctness of a release depend on QML's mirror of the bridge state agreeing with the bridge. That mirror starts as a binding but is imperatively reassigned by the signal handler, which breaks the binding permanently, so from then on it is only as accurate as signal delivery; one missed emit and the toggle would turn Shift on, on a page that has no Shift key to clear it and where a held modifier makes 1 emit ! under a keycap still reading 1. There is now an idempotent releaseShift() bridge slot that asks for the end state instead of a flip, so it cannot fail that way regardless of what QML believes.
  • Compact View: the Number Row panel overhung the window and clipped its last key. Reported with a screenshot. The panel is thirteen 1-unit keys and the compact grid is thirteen units wide, so the two should have been identical; the panel came out 10 px wider at the default window size. Cause was RowLayout: QtQuick.Layouts rounds every child up to a whole pixel, so thirteen keys of 69.23 px each became thirteen of 70. The keyboard rows are plain Row positioners, which keep keyW as the float it is. Both the Number Row and the Function Row are now plain Rows too, and the panel renders exactly the width of the widest keyboard row at every size tested. The regression test asserts that equality rather than merely asserting the panel fits the window, because the broken version also fit at some widths.
  • Compact View: Esc, Tab, Shift, Backspace and Del are now accent-coloured. Requested. The compact grid is uniform by design, which removes the size cues the full-size layouts rely on: there, Backspace and Shift are findable because they are visibly wider. On the compact layouts every key is the same 1x1 tile, so the keys reached for without looking had nothing to distinguish them from the letters around them. They are now filled with a wash of the theme accent over the theme's own key colour, plus an accent-coloured border. Not the raw accent: three of the nine themes have a pale accent (Blackboard, Spaceship) and Typewriter is a light theme with near-black text, so a saturated fill would have made the labels unreadable. The wash strength is derived rather than fixed, because a flat 35% turned out to have the same problem in miniature: measured against every theme it dropped the label below WCAG AA on five of the nine (Blackboard 6.19:1 to 2.66:1, Vaporwave 6.17 to 2.97, Forest 7.53 to 3.33, Spaceship 10.37 to 3.85, Ocean 6.96 to 4.44), which is the worst possible place to lose contrast, since these are the keys the change exists to make findable. Forest could not be rescued by swapping the label to black or white either (best case 4.37), so the wash itself yields: the alpha walks down from 0.35 until the theme's own text colour clears 4.5:1, leaving five themes untouched and backing the others off to 0.12-0.33. Where it has to back off, the border carries the cue instead, since a border sits beside the label rather than behind it and costs no contrast at all. The full-size layouts are deliberately unchanged.
  • Compact View: holding Shift on the ?123 page showed nine duplicate keys. Reported directly. With Shift held, row 1 re-rendered as ! @ # $ % ^ & * ( ) while row 3 already showed ! @ # $ % : & ( ) permanently, so nine of the keys on screen were saying the same thing as another key on screen, and the page wasted a third of itself. The two rows had been designed independently: row 3 exists because a shifted variant is invisible (the keycap reads ;, so nothing tells you : is one right-click away), and nobody checked it against what Shift already produced one row up. Shift's slot on the symbol pages is now a switch to a second symbol page (=\<), which is the phone convention and, more usefully, makes the overlap structurally impossible instead of merely absent: every glyph Shift used to reach now has a key of its own. The new page carries what ?123 lacks, ~ ^ * _ + { } | < >, then maths (° × ÷ ± ≈ ≠ ≤ ≥), then currency and legal (€ £ ¥ ¢ § • © ® ™, the bullet in the pilcrow's slot, since bullets are typed far more often), taking the compact layout from 28 reachable symbols to 56. All three layers are 13.0 units with matching key counts, so hopping between pages never changes a key's size or position, and the bottom row and right-hand navigation column stay identical throughout. The shifted fields are deliberately left on the symbol keys: right-click still types them, which adds reach without adding anything to the screen. One non-obvious consequence handled along the way: because a modifier is held at the OS level, a Shift carried in from the letters page would have made 1 emit ! while the keycap still read 1, and the symbol pages have no Shift key to clear it from, so a layer switch now drops a held Shift (Caps is untouched, since it only affects letters). Guarded by a property test that states the rule rather than the fix, no key's shifted variant may duplicate a glyph another key on the same layer already shows, which names all nine offenders when run against the old data.
  • Turning on Swipe Typing made Backspace, and every other special key, a dead tap. With swipe typing enabled, tapping Backspace, Delete, Tab, Enter, the arrows, Home/End, PgUp/PgDn, the modifiers, Compact View's ?123 key or the Number Row's Esc did nothing at all: no keystroke, no press visual, no repeat. Backspace is the one that mattered, because a user who enables swipe loses the single key an imprecise typist depends on most, and the failure is silent, so it reads as the app hanging. Not a timing or hit-testing bug but a structural one: the overlay covers the whole keyboard block with preventStealing: true and so takes every press, then resolved which key was pressed through charKeyRegistry, a list that by construction admits only single-character keys. A tap on Backspace hit-tested against a list that could not contain it. The tempting fix, letting specials into that registry, would have corrupted swipe decoding, because the same list feeds the recogniser's key-centre map, where a backspace centre is a phantom letter in every shape match. The two consumers now have separate lists: charKeyRegistry stays exactly as strict for the recogniser, and a new tappableKeyRegistry carries every key for hit testing. Restoring the tap alone would still have left "hold Backspace to delete a word" broken, which on a mouse-driven OSK is most of what Backspace is for, so a press over a special key now activates and holds it, with full auto-repeat, and dragging off aborts it exactly as it does without the overlay. That is safe because a gesture starting on a non-character key can never become a legitimate swipe. Character keys are untouched and still activate on release, since until the gesture ends it is genuinely ambiguous whether it is a tap or a swipe. The overlay drives keys through new KeyButton.externalPress() / externalRelease() functions that run the same debounce, press visual, ripple, activation and repeat-timer code the button's own MouseArea runs, so a key cannot behave differently depending on whether swipe happens to be on. Hit testing now also skips hidden keys: a KeyButton inside a hidden panel is still constructed and still registers, so with the Number Row switched off its keys sat in the registry with stale geometry. New tests/test_qml_swipe_overlay.py taps real keys through the real overlay in a headless Main.qml and asserts the keystroke reached the synthesizer; 12 of its 15 tests fail against the previous code.
  • Suggestion pills were still truncating ("dwelli…", "dwellin…") despite the fix above. Reported with a screenshot of eight dw candidates in Compact View, four of them elided. Two bugs, the second hiding the first. (1) The fitter reserved less horizontal padding than the pill actually consumes: computeFit floored a pill's padding at predHorizontalPad * 0.45 while the pill's text reserved predHorizontalPad * 0.28 on each side, i.e. 0.56 of it. For any window wider than about 700 px, and for all of Compact View, the width the fitter called "the narrowest this word can render in" was 1-5 px narrower than the word actually needs. Pills sized from their text were therefore born too narrow, and the no-truncation guarantee held only when the leftover-space redistribution happened to top them back up; with a full row and nothing left over, they elided. The inset is now a single property that both the fitter and the pill read, so the two cannot drift apart again. Measured across four candidate sets, both view modes and 120 window widths: 618 of 5033 rendered pills elided before, 0 of 4911 after. (2) The test that was supposed to prevent exactly this could not fail. It collected pills through Qt's QObject child tree, where a Repeater's delegates do not live, so it had been asserting over an empty list since it was written and a whole test class named "no pill is ever truncated" was passing against zero pills. The lookup now walks the visual item tree, the assertions check they actually found something, and the new regression test sweeps 260 window-width and view-mode combinations because the failure is a knife-edge: it was verified to fail at 130 of them before the fix, while the single most obvious width to spot-check by hand (940 px, full size) did not reproduce it at all.
  • Pushing the Win key on Linux trapped the pointer in a window-manager move/resize mode. Reported as: pressing Windows/Super gets the keyboard stuck in a "right-click type scenario." Cause was the sticky-modifier hold — tapping Win called xdotool keydown super, leaving Super physically held at the X server. Every Linux window manager (Mutter, KWin, …) grabs the pointer while Super is held to drive window gestures (Super+drag = move, Super+right-button = resize), so every subsequent click — including clicks on the OSK's own keys — was swallowed as a WM gesture instead of reaching the keyboard. Win only auto-releases after a keypress, but the user could no longer land one, so they were stuck with no way to toggle it back off. Fixed by never holding Super on Linux: LinuxKeySynthesizer.hold_modifier() now skips win/super entirely. Super+<key> combos (Win+D, Win+L, Win+arrow) still work because send_key() emits them as an atomic xdotool key super+<key> chord that presses and releases Super in one shot — the hold was the only thing creating the trap, and it buys nothing on an on-screen keyboard (you can't Super+drag with the same mouse you click keys with). Shift/Ctrl/Alt are still held so Shift+drag selection etc. keep working in the target app; Windows still holds VK_LWIN (the grab is Linux-specific). Also added KeyboardBridge.resetModifiers(), called from Main.qml's Component.onCompleted, which drops any sticky Shift/Ctrl/Alt/Win left held from a prior run, a crash mid-chord, or an external grab and clears the on-key highlights, so a session always opens in a clean state (Caps Lock is left intact — it holds nothing at the OS level and is a deliberate persistent toggle). Coverage in tests/test_platform.py::TestLinuxSuperNeverHeld and the resetModifiers cases in tests/test_keyboard_bridge.py.
  • Clicked keys did nothing in games (reported against Age of Empires). The Windows on-screen keyboard worked in the same game; ours didn't register at all. Cause was the synthesis timing, not privileges: every keystroke was injected as key-down + key-up in a single zero-gap SendInput batch. Normal Windows text controls react to the resulting message no matter how briefly the key was held, but games read the keyboard by polling state once per render frame (DirectInput / Raw Input / GetAsyncKeyState), so a key that went down and back up inside one frame fell between two polls and was never seen. This was unrelated to UIAccess: the signed Program-Files build still hit it because the keystrokes reach the game, they were just too brief to be polled. Fixed with auto game-compat: when the foreground window is a game, single keys are now synthesised as down -> 50 ms hold -> up (two SendInput calls with a real sleep between, modifiers held across), which spans at least one keyboard-state poll the same way the OS on-screen keyboard holds a key for the mouse-click duration. Detection (_window_is_game in keyboard_bridge.py) uses two signals: the foreground exe is in a curated game list (_GAME_PROCESS_NAMES, seeded with the Age of Empires family), or a borderless-fullscreen heuristic (the window covers its whole monitor and has no title bar) as a zero-config catch-all for unlisted games. The heuristic is skipped for known IDEs / remote-desktop clients so a fullscreen editor or RDP session keeps the instant atomic keystroke path with no typing lag, and "no title bar" excludes normal maximized windows. Non-game typing is completely unchanged. Coverage in tests/test_keyboard_bridge.py::TestGameKeyHold. Mirrored into the C++ rewrite (winutil::windowIsGame / windowIsBorderlessFullscreen, KeyboardBridge::keyHoldSeconds, WindowsKeySynthesizer::sendKey's holdSeconds split).
  • Shift (and Ctrl) stopped being held when an arrow key was pressed or held. Reported as: holding Shift and then pressing an arrow key drops the Shift hold, so you cannot extend a text selection with the arrow keys. Cause was the sticky-modifier auto-release in pressSpecialKey firing on every special key, including the cursor-movement keys. After the first arrow press it released Shift/Ctrl, and an auto-repeating held arrow lost the modifier after its very first tick. Fixed by exempting the navigation keys (left/right/up/down/home/end/pageup/pagedown, collected in a new _NAV_KEYS set) from the Shift/Ctrl auto-release: while a modifier is held, pressing a nav key now keeps it held so Shift+arrow extends a selection and Ctrl+arrow / Ctrl+Shift+arrow jump or select by word across multiple presses, exactly like Shift+click and Shift+drag. You tap the modifier again to release it when done. Alt and Win still auto-release even on nav keys, since their arrow combos (Alt+Left = back, Win+arrow = window snap) are one-shot actions. Coverage in tests/test_keyboard_bridge.py (test_shift_persists_through_arrow_key, test_ctrl_persists_through_arrow_key, test_shift_still_releases_after_tab).
  • Windows taskbar icon reverting to the generic default after launch. Reported as: the installed build shows the correct Alpha-OSK icon on the taskbar while it starts, then drops to the blank default icon the moment the keyboard window appears. Cause was a missing AppUserModelID — the taskbar button is created at launch with the exe's embedded icon, but once the Qt window shows, Windows re-derives an app identity from the bare process and can no longer tie the button back to our icon, so it falls back to the default. The .ico itself was fine (six resolutions, 16-256 px) and setWindowIcon was already being called; the gap was that nothing ever claimed an explicit identity. keyboard_app.py now calls SetCurrentProcessExplicitAppUserModelID("OKStudio.AlphaOSK") once at startup, before QApplication and therefore before the first window exists (the call has no effect if Windows has already cached the derived identity). Best-effort and Windows-only: a failure costs only the taskbar icon, never startup, and the helper is a no-op on Linux/macOS. The installer's Start Menu / Desktop shortcuts still use the exe's embedded icon and do not yet stamp the matching AppUserModelID, so a pinned shortcut won't group with the running window until that follow-up lands; the reverting-icon symptom is fully resolved by the runtime call.
  • Auto-update was permanently and silently broken for any Windows account whose username contains an apostrophe (an account named O'Brien, for example). The installer-signature PowerShell call interpolated the install path into a single-quoted string, so a path containing an apostrophe produced an unparseable script; verification returned false and the app behaved exactly as if no update were ever available, with nothing in the UI or the log to explain why. Found while verifying the installer's embedded-version check, not by the audit itself. The path is now escaped before it reaches PowerShell. Coverage in tests/test_updater.py::TestPowerShellSingleQuoteEscaping.
  • On Linux, typed text that happened to match an xdotool or ydotool flag was silently swallowed instead of typed, and a wedged synthesis subprocess could freeze the keyboard indefinitely. Typed text was the final argument in every type call with nothing forcing positional interpretation, so a word or snippet matching a real flag token from either tool's own option list (checked directly rather than assumed) was parsed as a flag and produced no keystroke at all: a silent failure on an accessibility tool where every keypress matters. Separately, the subprocess call had no timeout while running synchronously on the Qt UI thread, so an unresponsive X server or a wedged binary froze the whole keyboard with no way to recover short of killing the process. The four call sites that carry arbitrary text now pass a literal -- before it, and the call itself is now bounded at 2 s, roughly two orders of magnitude above normal synthesis, with a timeout logged and swallowed like any other send failure. Coverage in tests/test_platform.py::TestLinuxSendText and TestLinuxRunTimeout.

Security

  • Typed text was being written to a plaintext log. alpha-osk.log, the rotating INFO-level file (2 MB times 3 backups, in the config dir) that exists so a frozen build with no console can still capture updater errors and crash tracebacks, was also recording what the user typed. Three log calls inside pressPrediction interpolated the accepted word and its context into every prediction tap, and the worst of them logged _context_buffer, which mirrors the on-screen text up to 200 characters, so the file accumulated a running transcript of typed content over time. None of the affected call sites checked _privacy_mode, so turning Learning off in a password field stopped the word from reaching the prediction model but did nothing to stop the same word landing in the log a moment later. This is also the exact file the app asks users to attach to bug reports. Nine call sites across keyboard_bridge.py now log lengths and booleans instead of content, and CLAUDE.md records the new invariant: nothing at INFO or above may carry typed text. Coverage in tests/test_keyboard_bridge.py::TestNoTypedContentInLogs, which types a sentinel word through every logging path and confirms it never reaches the log.
  • Accepting a prediction pill bypassed privacy mode entirely. pressPrediction and editPrediction never called the per-keystroke password-field check and had no privacy gate at all, unlike _press_char, pressSpecialKey and processSwipe, which all check before touching prediction state. Clicking into a password field with a stale suggestion still on screen and tapping it, before the 200 ms polling timer caught up, committed that word to both analytics.json (via record_prediction_selected) and ngram_model.json (via learn_from_selection and learn_capitalization). Both bridge slots now call _check_password_field_sync() first and suppress the analytics record and the model learning while privacy mode is active, while still typing the tapped word into the target app, since the user did tap a pill they could see and that word has to reach the app regardless. Guarded by tests/test_keyboard_bridge.py::TestPressPredictionPrivacyMode and TestEditPredictionPrivacyMode, which assert the insertion still happens while analytics, learning and capitalization all stay untouched.
  • A malicious backup archive could plant a command that ran on one tap. Snippet values are typed verbatim through _send_text, and xdotool type turns an embedded newline into a real Return, so an imported snippets.json could carry a shell command under an innocuous label like "Email" that fired the instant the user tapped it with a terminal focused. The Data Backup import preview shows filenames and counts, never snippet contents, so a poisoned snippet was invisible before the user committed to the import. Imported values now have embedded newlines flattened to spaces; locally authored multi-line snippets, such as a mailing address, are unaffected, since only values arriving through an import are flattened. Coverage in tests/test_data_export.py::TestSnippetNewlineFlattening.
  • The Windows installer read its install directory from a user-writable registry key. The generated NSIS script carried InstallDirRegKey HKCU next to RequestExecutionLevel admin, and nothing in the build ever wrote HKCU\Software\Alpha-OSK, so in normal operation that key exists only if something else put it there, which needs no elevation at all. Unprivileged local malware could create it and wait: the next silent auto-update would run the elevated, EV-signed installer and deposit the app into an attacker-writable directory, where the exe and its DLLs become replaceable and Windows silently stops granting UIAccess. The directive is gone, and the updater now pins the target with /D=, computed from the running frozen executable so a legitimate custom install still updates in place. Guarded by tests/test_updater.py::TestInstallTargetDir and TestLaunchInstaller.
  • The auto-updater never checked the installer's own embedded version before running it. _verify_signature already checked Authenticode status, thumbprint and signer CN, but the release asset's filename pattern is a selector, not a trust boundary, so someone able to publish a release without holding the signing key could re-attach a genuine older installer under a newer version's name and roll users back onto a build with known, already-fixed bugs. Verification now also reads the executable's FileVersion and requires it to match the target version on its first three components. Coverage in tests/test_updater.py::TestEmbeddedVersionPin.
  • run.py elevated to admin before installing Python dependencies. ensure_admin_windows() ran as the first statement in main(), so pip install and every module it pulls in from a user-writable tree executed with an admin token. Elevation now happens only after dependency installation and the Python version check both succeed. Coverage in tests/test_run.py::TestMainOrdering.
  • The Linux AppImage build downloaded and ran appimagetool from a mutable tag with no verification. It fetched from the continuous release, which upstream rebuilds on every merge, and executed whatever came back with no check at all. The build now pins a specific tagged release (1.9.1) and verifies the download's SHA256 before it is ever executed, refusing and deleting the file on a mismatch.
  • The telemetry worker had no rate limiting, despite the docs saying it did. handleSubmit accepted any client-chosen anon_id and inserted a row per unique id with nothing bounding row count, so a script generating fresh ids could exhaust the free tier and inflate D1 storage. Two layers now exist, both keyed on anon_id: a Cloudflare rate-limit binding on each POST route, and a one-hour cooldown enforced inside the D1 upsert. Neither reads a request header, since the worker deliberately never touches IP, geo or user-agent, and the fix must not be the thing that breaks that promise.
  • Vocabulary packs, analytics.json and Data Backup archive extraction all gained the input caps every sibling loader already had (analytics.json now rejects files over 5 MB before parsing and caps key_freq at 5000 entries, the limit word_freq already had; packs gained per-file and total-import size caps plus a ceiling on dictionary, bigram and trigram entry counts). Separately, two distinct causes of a half-applied import are fixed: a pack id of con, nul, lpt1 or another reserved Windows device name passed the existing sanitisation and then raised an uncaught error on the actual filesystem write, and a corrupt archive raised an uncaught BadZipFile, both after model files, analytics and snippets had already been replaced. A reserved name is now skipped so the rest of the import still succeeds, and a corrupt archive now raises a handled DataExportError instead of failing silently partway through. Guarded by tests/test_data_export.py::TestReservedPackNames and TestBoundedCopy, plus tests/test_vocabulary_pack.py::TestPackInputCaps.
  • Dependencies are now pinned to exact versions, and ruff-pre-commit moved from the mutable tag v0.4.4, about sixty releases behind, to a commit SHA matching the ruff version CI already enforces. The macOS-only pyobjc entries stay on floor versions, since there is no macOS venv in this environment to verify an exact pin against.

Internal

  • The pre-push gate went from twenty-five minutes to one, and can now run itself. python check.py had grown slow enough to be skipped, which is the only way it fails. The obvious fix is to run less of it, and measuring first found something better: lint and both type-check passes cost about five seconds between them, so the gate was the tests, and the tests were slow for a reason unrelated to how many there are. Building a KeyboardBridge cost roughly a second (a 20 000-word dictionary, a SymSpell deletion index, a PPM model), the fixture builds a fresh one per test, and there are some 1300 tests. That is per-process setup repeated 1300 times: untouched by any amount of clever test selection, and almost perfectly parallel. Sharding it with pytest-xdist (-n auto, now a pinned dev dependency) takes the whole gate to ~60 s, and --full with the coverage gate to ~110 s. Halving the per-bridge cost was a genuine bug fix on its own, listed under Fixed above.

    There is deliberately no fast-subset mode, and the reasoning is recorded in check.py itself so it is not re-litigated: at a minute for everything, skipping tests would buy seconds while giving up precisely the coverage the script exists to provide. python check.py --install-hook writes a pre-push hook so the gate runs on git push rather than from memory (--no-verify skips it, --serial reproduces a parallel-only failure in one process).

    Sharding needed one test-side fix worth knowing about, since it will recur. The four headless QML modules persist through a QML Settings {} element, which resolves to a process-external store, a key under HKCU on Windows. Three of them defined the same scope literal independently and the fourth imported it, so under -n auto several workers shared one scope and cleared each other's keys mid-test. It surfaced as "the window width drifted across restarts: [1160, 940, 1160]", which is indistinguishable from the persistence bug those tests exist to catch, and is the most expensive shape a flake can take. The scope now lives once in tests/qt_settings_scope.py, keyed by worker. Any new test touching QSettings, the registry, a fixed path or a port has to do the same. CI is deliberately left serial for now: it is the release gate, its runners have far fewer cores, and the local loop was the problem being solved.

  • The test suite no longer reads the developer's live desktop. KeyboardBridge is constructed for real in the bridge tests, and two of its inputs are the machine it happens to be running on: is_password_field() is called synchronously on every keystroke, and the new outside-click probe is polled on a timer. On a Windows dev box both answer questions about whatever window the developer left focused, so a password field on screen flipped the bridge into privacy mode mid-test and every _current_word assertion after it failed. That shape of failure is the worst kind: it passes in isolation, passes on re-run, and always passes on CI, where Linux has no detector at all. An autouse fixture in tests/conftest.py now stubs both to inert values, and any test wanting the other answer patches the same name and wins.

  • Minimum Python is now 3.10, because 3.9 was claimed but enforced by nothing. pyproject.toml set mypy's python_version = "3.9", which mypy no longer accepts as a target: it rejected the value at startup with a warning and silently fell back to its own default, so the type checker had never once verified the declared floor. CI compounded it by running a single-version matrix (3.11), so nothing anywhere in the pipeline would have caught 3.10-or-newer syntax reaching src/. Two ways out: raise the floor to the oldest version mypy will target, or add 3.9 to the test matrix and prove the claim. Raised the floor, since the shipped installer bundles its own interpreter and the claim only ever affected contributors running from source. python_version is now "3.10", and the runtime assertions in run.py::check_python_version and the three build/*/build.py scripts were moved with it so a contributor gets one consistent answer. Worth knowing what this does and does not buy: mypy targeting 3.10 while CI executes 3.11 means 3.10 is type-checked but never run, so a stdlib behaviour change between the two would still slip through. The remaining gap is narrow and now written down rather than assumed away.

  • ruff format is now a CI gate, and the tree was reformatted to satisfy it. ruff check does not look at layout, so formatting was never actually enforced anywhere: CI and check.py both stopped at ruff check, and the ruff-format hook declared in .pre-commit-config.yaml only fires for contributors who have run pre-commit install. The result was silent drift in 46 of 57 files under src/ and tests/, which surfaced as unrelated reformatting noise whenever anyone ran the formatter over a file they were editing. Two commits: one applying ruff format src/ tests/ (whitespace only, no behaviour change, kept separate so it can be skipped wholesale in git blame), and one adding ruff format --check src/ tests/ as a fourth gate in both .github/workflows/ci.yml and check.py so it cannot drift again. Fixing a failure is ruff format src/ tests/, never by hand. Also fixed a latent bug in check.py's missing-tool probe, which assumed every step's label was also an importable module name (_have_module("format") would have reported ruff as missing); it now probes the module named in the command instead.

  • CI: install Qt's GL/xkb system libraries on the Linux test runner. PySide6 bundles its own Qt, but Qt still dlopens the host's libEGL / libGL / xkb libraries, and the ubuntu-latest runner image carries none of them. The headless QML tests import PySide6.QtGui at module scope, so the resulting ImportError surfaced as a pytest collection error, which aborts the whole run rather than failing two modules: every Linux test in the suite stopped reporting. The test job now installs the libraries so those tests actually execute, since they are the only guard against a QML binding error shipping as a blank keyboard and skipping them on CI would defeat the point. Both QML modules additionally degrade to a module-level skip naming the missing library instead of a collection error, so a contributor on a bare headless box still gets the rest of the suite.

  • Refreshed the Cloudflare Worker's transitive dependency pins. The overrides block in backend/cf-worker/package.json had gone stale as new advisories landed against the versions it pinned, and the OSV gate (fail-on-vuln: true) blocks every PR on a new advisory by design. Bumped undici to 7.29.0, tar to 7.5.21, js-yaml to 4.3.1, brace-expansion to 2.1.4 and fast-uri to 3.1.5, and added an ip-address pin at 10.3.1 that was missing entirely (three advisories). All are dev-only transitive dependencies of Wrangler; none ship in the keyboard.

  • Property-based test suites for the import paths and the engine invariants. Adds Hypothesis and two suites (tests/test_property_*.py). The import-hardening suite states the property the hardening exists for — nothing outside the destination directory is ever created, modified or removed, whatever the archive or folder is called — and asserts it end-to-end for both the Data Backup archive path and PackManager.import_pack against a sandbox holding a canary tree, alongside the allow-list, size-cap and round-trip invariants. The engine suite checks _user_total == sum(user_vocab.values()) after every individual mutation across generated operation sequences, plus the spatial model's normalisation and the _context_buffer / _current_word accounting. Deterministic by construction: the profile in tests/conftest.py disables the example database, so a run cannot pass locally and fail on CI from a stale corpus. No production code changed.