From e819a8d0bf37074feef73da3f546b4c77ffe9883 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 3 Oct 2026 12:13:36 -0700 Subject: [PATCH] update kiro specs for mobile interface --- .kiro/specs/calculator/design.md | 594 ++++++++++++++++++------- .kiro/specs/calculator/requirements.md | 21 +- .kiro/specs/calculator/tasks.md | 332 +++++++++++--- 3 files changed, 735 insertions(+), 212 deletions(-) diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index f5573fe..65cbae8 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -1165,65 +1165,67 @@ upward across many roundings. ## 6. C API (for Android/FFI) -**What exists today:** `engine/src/c_api.zig` is the root of the shared library -(`build.zig` builds `libtally.so` from it), and it declares three exports. -`tally_version` works. `tally_eval` and `tally_result_free` are signatures with -`// TODO: implement` bodies, so the library loads, exports the symbols, and answers -every evaluation with an empty `CalcResult`. No test builds it, so it appears in no -coverage report (open items 12 and 15). +**What exists today:** `engine/src/c_api.zig` is the root of the shared library and +implements the design below. Twelve exports, a hand-written `include/tally.h`, and two +test layers: Zig tests that build sessions with `std.testing.allocator` so the whole seam +is leak-checked, and `engine/test/c_abi_test.c`, which links against the real library +through the real header and is run by `zig build test`. `zig build android` +cross-compiles the library for arm64-v8a, x86_64 and armeabi-v7a, laid out for Gradle's +`jniLibs`, with the JNI layer (6.4) on top. -The rest of this section is the design, and the three decisions it does not yet make -are listed at the end. +What is not built: the financial and float-view payloads. Standard mode, programmer +mode, unit conversion, preview (6.5), whole-category conversion (6.6), save and load +(6.7) and the units +catalogue are across. ```zig -// c_api.zig - extern "C" exports -// -// SAFETY: All string inputs use pointer + length (not null-terminated). -// This avoids buffer overread vulnerabilities and allows embedded nulls. -// All returned strings include a length - caller must free with tally_result_free(). +pub const Status = enum(c_int) { ok = 0, eval_error = 1, out_of_memory = 2, invalid_argument = 3 }; +pub const Mode = enum(c_int) { standard = 0, programmer = 1 }; -pub const CalcResult = extern struct { - /// JSON-encoded result. Null if error. - json_ptr: ?[*]u8, - json_len: usize, - /// Error message if json_ptr is null. - error_ptr: ?[*]u8, - error_len: usize, +/// Interpretation and display, set once on the session rather than per call. The +/// display fields are NFR-9.9: the engine holds no digit budget of its own. +pub const Config = extern struct { + bits: u16 = 64, // 8, 16, 32, 64, 128 + is_signed: u8 = 1, + big_endian: u8 = 1, + separators: u8 = 1, + never_abbreviate: u8 = 0, + fraction_digits: u16 = 20, + max_integer_digits: u16 = 40, + significant_digits: u16 = 17, + scientific_below_exponent: u16 = 15, }; -/// Evaluate an expression, return JSON result. -/// Caller must free the result with tally_result_free(). -export fn tally_eval( - expr_ptr: [*]const u8, - expr_len: usize, - mode: c_int, // 0=standard, 1=programmer, 2=financial - config_ptr: ?[*]const u8, // optional JSON config (null if none) - config_len: usize, // 0 if no config -) callconv(.c) CalcResult; - -/// Convert a value between units, return JSON result. -export fn tally_convert( - value: f64, - from_ptr: [*]const u8, - from_len: usize, - to_ptr: [*]const u8, - to_len: usize, -) callconv(.c) CalcResult; - -/// Free a CalcResult's buffers. -export fn tally_result_free(result: *CalcResult) callconv(.c) void; - -/// Get version string (static lifetime, do not free). -export fn tally_version(out_len: *usize) callconv(.c) [*]const u8; +export fn tally_session_new() ?*Session; +export fn tally_session_free(?*Session) void; +export fn tally_session_configure(?*Session, ?*const Config) Status; +export fn tally_session_config(?*Session, ?*Config) Status; +export fn tally_eval(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status; +export fn tally_preview(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status; +export fn tally_convert(?*Session, ?[*]const u8, usize, ?[*]const u8, usize, ?*[*]const u8, ?*usize) Status; +export fn tally_session_save(?*Session, ?*[*]const u8, ?*usize) Status; +export fn tally_session_load(?*Session, ?[*]const u8, usize) Status; +export fn tally_unit_catalog(?*Session, ?*[*]const u8, ?*usize) Status; +export fn tally_version(?*usize) [*]const u8; +export fn tally_abi_version() u32; ``` -A `tally_struct_layout` export was specified here too; it goes with section 3 and is -not part of the first Android surface. - Design notes: - **No null-terminated strings.** All inputs take `(ptr, len)` pairs. This prevents buffer overread, allows binary data in expressions if ever needed, and is the idiomatic C pattern for length-known strings. -- **`CalcResult` struct** bundles success/error in a single return. Caller checks `json_ptr != null` for success. -- **JSON result format** keeps the Android/FFI boundary simple - Kotlin parses JSON natively. On the JNI side, the Kotlin bridge reads `(ptr, len)` into a `ByteArray` and decodes UTF-8. +- **A session, not a function.** `Environment` outlives an evaluation, so the handle does too: `x = 5` then `x * 2` works, and `Ans` has a value. This is what the one-shot `tally_eval` in the original sketch could not do. +- **`tally_result_free` is gone.** Results are borrowed from the session (6.2), so there is nothing for a caller to free and no way to free it wrongly. +- **JSON payloads**, written by `std.json` rather than by hand, so escaping is not a local invention. A standard result is + `{"ok":true,"display":"256","exact":true,"truncated":false,"bases":{"hex":"01 00",...}}`, + with `bases` present only when the expression used a non-decimal literal (FR-1.9) and + `conversion` present only when it was a unit conversion. Programmer mode returns every + row the screen shows. An error is `{"ok":false,"error":"division by zero"}`, worded by + `engine.phrase`, which is where the CLI and TUI get the same sentence. +- **Statuses distinguish three failures**: the expression was bad (`eval_error`, with the + reason in the JSON), the machine is out of memory (`out_of_memory`, with no JSON, + because there may be no memory to write it), or the call itself was malformed + (`invalid_argument`, which touches nothing). +- **A units catalogue**, so a picker is filled from `units.unitsIn` and the aliases table + rather than from a list retyped in Kotlin. ### 6.1 The decisions this design still owes @@ -1314,15 +1316,22 @@ and the returned value from the allocator it is handed. So: | One call: parse tree, intermediate rationals, rendered text | nothing; the arena owns it | `ArenaAllocator` over the session allocator, `reset(.retain_capacity)` at the end of each call | | The bytes handed back | the session's result buffer | session allocator, `clearRetainingCapacity` per call | -- **The session allocator is `std.heap.smp_allocator`** at the C boundary: thread-safe - by construction, aimed at release builds, and verified to compile for both Android - ABIs without libc. Not `page_allocator` directly - that rounds every allocation up to - a page, and a variables map plus big-integer limbs would burn one each. `page_allocator` - is simply where `smp_allocator` gets its memory. +- **The session allocator is `std.heap.page_allocator`** at the C boundary, and the + loader chose it, not taste. The Android library declares no dynamic dependencies (6.3), + so every symbol it references must be one it defines: `smp_allocator` keeps its + per-thread state in a thread-local, which emits a TLS segment and an undefined + `__tls_get_addr` that only Bionic's libc can satisfy. It links on a desktop and fails in + `dlopen` on a device, before any code runs. `std.heap.DebugAllocator` over + `page_allocator` is free of that symbol and is the upgrade if page granularity ever + costs anything measurable; what rules out page granularity being a problem today is the + arena below, which buys pages in chunks and hands out bytes. The allocations that do hit + `page_allocator` one for one are `Environment` variable clones and the geometric growth + of the result buffer, both bounded by what a person types. - **The allocator is a parameter everywhere above the boundary.** The Zig-visible session type takes an `Allocator`, so tests pass `std.testing.allocator` and get leak detection; only the `export fn` picks a default. That is how the ABI gets leak-checked - without the library carrying a debug allocator into production. + without the library carrying a debug allocator into production. It is also what made the + fix above a one-line change to one function. - **`retain_capacity` is what makes the steady state cheap.** The first evaluation buys the arena's pages and every later one reuses them, so a calculator being typed into stops calling the backing allocator almost entirely. @@ -1349,7 +1358,8 @@ Building the engine for `aarch64-linux-android` and `x86_64-linux-android` produ shared object per ABI with **no dynamic dependencies at all** (zero `DT_NEEDED` entries), containing the full exact tier: a probe that evaluated `2^100 + 1` and rendered it through `Number.render` compiled and linked on both, at 230KB under `ReleaseSmall`. No -NDK was involved, and the three-lifetime design above compiles for both ABIs too. +NDK was involved, and the three-lifetime design above compiles for both ABIs too. The +shipped libraries, with the panic machinery removed below, are 170KB to 186KB. Asking for `-lc` on those targets fails outright: Zig 0.16 bundles glibc and musl and no Bionic, so it reports `unable to provide libc for target 'aarch64-linux...android.29'` @@ -1360,32 +1370,104 @@ that needs no NDK at all. The cost is that allocations do not pass through `mall Android's malloc-based tooling cannot attribute them; for a calculator whose steady state is an arena reset per keystroke, that is a fair trade. -Still assumed, and only testable with a device or emulator: that the library loads under -`System.loadLibrary`, that a JVM resolves the symbols, and that a page-backed allocator -behaves well under Android's memory pressure. +**No longer assumed.** The library loads under `System.loadLibrary`, the JVM resolves the +symbols, and the engine computes correctly on a device: on an Android 15 x86_64 image with +16 KiB pages, `2+3*4` gave `14`, `100 km to mi` gave +`62.13711922373339696174 mi`, `x = 7` then `x * 6` gave `42` - which is the +`Environment`-survives-the-arena-reset rule holding across calls - and `1/0` reported +`division by zero` rather than crashing. The 10 instrumented tests pass on the same image. +What remains untested is a physical device, arm64-v8a in particular, and behaviour under +real memory pressure. + +**What a library with no `DT_NEEDED` actually costs, found on a device.** The zero above +is a constraint, not just a property, and the first build that ran on an emulator did not +load at all: + +``` +java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "__tls_get_addr" + referenced by ".../lib/x86_64/libtally.so" +``` + +Nothing in the engine asked for a libc symbol on purpose. Three pieces of std did, all on +paths that had nothing to do with arithmetic: + +| Symbol | Pulled in by | Removed by | +|---|---|---| +| `__tls_get_addr` | `smp_allocator`'s thread-local | `page_allocator` (6.2) | +| `__tls_get_addr`, `getauxval` | std's default panic handler: a thread-local recursion guard, and reading the library's own ELF headers to print a stack trace | a panic handler that writes the message to stderr and traps, letting Android's own crash handler produce the tombstone | +| `getauxval` (arm64-v8a only) | `std.heap.pageSize()`, a runtime auxv query wherever Zig's `page_size_min` and `page_size_max` differ - which is aarch64 alone, at 4 KiB to 64 KiB | `std.options.queryPageSize` returning 16 KiB, with only the *maximum* overridden | + +Two things about that last row are worth keeping. It reproduced on **arm64-v8a only**, +so the x86_64 emulator that caught the first failure would have passed it while most real +phones failed - the symbol audit below is what catches that class, not testing. And +`page_size_min` was deliberately left alone: std documents it as the alignment a caller +may assume from a page allocation, and on a 4 KiB kernel `mmap` returns 4 KiB-aligned +memory, so raising it would promise an alignment the kernel does not give. Overriding the +ceiling and the query rounds lengths up, which is the safe direction. + +The general lesson is that a freestanding shared library's dependencies are decided by +code that never runs. Two of the three came from a debugging aid on the panic path, and +with `BIND_NOW` set - it is, on all three ABIs - even an unreferenced PLT entry is +resolved at load, so "nothing calls it" is not a defence. The audit is therefore part of +the build's evidence rather than a one-time check: for each ABI, zero `DT_NEEDED`, zero +undefined dynamic symbols, no TLS segment, and `LOAD` aligned to 0x4000. Removing the +panic machinery also took the libraries from ~290KB to ~170-186KB. ### 6.4 How Kotlin reaches the C ABI +**Decided: a hand-written `engine/src/jni.zig`, compiled only for Android, with no NDK.** + A Kotlin `external fun` cannot call `tally_eval` directly; something has to translate `jstring` and `JNIEnv*` into pointers and lengths (design 6.3). Three ways to get that -glue, in the order I would consider them: +glue were considered: -1. **`engine/src/jni.zig`, compiled only for Android.** The glue stays in one language, - is testable from Zig, and adds no dependency to the app. It needs the JNI type - definitions, which means either the NDK's `jni.h` at build time for that one file, or - hand-declaring the dozen function-table entries it uses. Hand-declaring removes the - NDK requirement entirely and puts a silent breakage risk in its place: a wrong vtable - index is a crash with no compiler to catch it. Prefer `jni.h`, and keep the C ABI - itself NDK-free so only this layer needs it. +1. **`engine/src/jni.zig`, compiled only for Android.** The glue stays in one language, is + testable from Zig, and adds no dependency to the app. It needs the JNI type + definitions, which means either the NDK's `jni.h` or hand-declaring what it uses. 2. **A small C shim compiled by Gradle** (`externalNativeBuild` with CMake). Standard Android practice, and it moves native compilation into Gradle, which this design has - so far kept out. -3. **JNA**, which calls a plain C ABI with no per-project glue at all. Cheapest to start - and the only option that needs no NDK anywhere; it costs a dependency and per-call - marshalling overhead that a calculator will never notice. + kept out. +3. **JNA**, which calls a plain C ABI with no per-project glue at all. Cheapest to start, + and it costs a dependency and per-call marshalling. -This does not need deciding until there is something to load. The C ABI is identical -under all three. +The first, without `jni.h`, because the library already builds with no NDK and no libc +(6.2) and dragging in a sysroot for three functions would undo that. + +**What that costs, and how it is held down.** `JNIEnv*` is a pointer to a table of +function pointers at fixed indices, so reaching `NewStringUTF`, `GetStringUTFChars` and +`ReleaseStringUTFChars` without the header means four integers have to be right, and a +wrong one is a jump into the wrong function rather than a compile error. Three things +guard it: + +- The table is declared at its full published length (233 entries since JNI 1.6, with + `GetObjectRefType` last), and a comptime check rejects an index past the end. The + arithmetic that arrives at exactly that length is written out in the file, because that + count is what catches a dropped entry. +- `JNI_OnLoad` proves the indices against the JVM that is actually running, before + anything else: it checks the version, then round-trips a string through `NewStringUTF` + and `GetStringUTFChars` and compares the bytes. On failure it returns `JNI_ERR`, so + `System.loadLibrary` throws at startup rather than the app crashing later inside an + evaluation. +- The tests run every entry point against a synthetic function table, covering the + marshalling, the handle arithmetic and the self-check in both directions (including a + deliberately broken table). They cannot check the indices themselves, because they + supply the table. +- **A real JVM can, and not only Android's.** JNI's function table is fixed by the + specification rather than by the platform, so a desktop JVM resolves the same indices an + Android runtime does. `zig build jvm-test` builds the library for the host, compiles + `engine/test/java/dev/lerch/tally/TallyEngine.java`, and runs it: `System.loadLibrary` + executes `JNI_OnLoad`, and the test then drives every entry point, including a catalogue + string over 4KB. Moving one index by a single slot makes that step fail, which is how we + know the check has teeth. It is a separate step rather than part of `zig build test` + because it needs a JDK and nothing else in the suite does. + +**The glue is also what makes borrowed results safe.** The bytes from `tally_eval` become +a Java string inside the same function that asked for them, so the app never holds a +native pointer and cannot outlive one. `c_api` leaves a NUL just past the reported length +for exactly this, so the copy the JVM makes is the only copy in the call. + +The C ABI is unchanged by this choice: a Swift or C consumer links the same library and +calls it directly. ### 6.3 Who the ABI is for, and what is actually Android-specific @@ -1460,8 +1542,95 @@ retrofit: product version and the ABI version are different facts and should be separately readable. +### 6.5 Preview: an evaluation that stores nothing + +**Decided and built, for the live result on the standard screen (9.2).** A screen that +answers as you type calls the engine on every keystroke, and `tally_eval` is the wrong +call for that: typing `x = 7` would assign `x` at `x =` ... `7`, and every intermediate +answer would overwrite `Ans`, so `Ans` would mean "whatever was last on screen" rather +than "what I last committed". Both are silent - the numbers are plausible - which is +the kind of wrong a calculator cannot afford. + +```zig +export fn tally_preview(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status; +``` + +Same arguments, same JSON, same borrowing rule as `tally_eval`; the only difference is +that nothing in the session changes. An assignment previews as the value it would store, +and a built-in target (`pi = 3`) previews as the error committing it would give. + +- **A separate function, not a flag.** A `bool` on `tally_eval` changes a signature that + shipped; folding a bit into `mode` makes one integer mean two things. A new export is + additive, and `abi_version` moves to 2 so a prebuilt library without it is refused at + startup rather than failing on the first keystroke. +- **The guarantee is the type, not a branch.** Assignment is a statement (Task 5.19): the + parser only produces it at the root, never inside an expression. So `evalStringInfo` + now handles the root assignment itself, and the recursive evaluator below it takes a + `*const Environment` - it *cannot* store anything. The preview path, + `engine.previewStringInfo`, is the same walk without the two writes at the end, and it + takes a `*const Environment` too, so "preview mutated the session" is a compile error + rather than a test. The CLI and TUI get the same function; neither uses it yet. +- **Programmer mode is already pure** - it never touches the environment - so its preview + is its evaluation. + +### 6.6 Convert: one value, every unit + +**Decided and built, for the converter screen (9.6).** + +```zig +export fn tally_convert(?*Session, value: ?[*]const u8, usize, unit: ?[*]const u8, usize, ?*[*]const u8, ?*usize) Status; +``` + +`{"ok":true,"input":"100","from":"C","category":"temperature","results":[{"unit":"C", +"display":"100","exact":true,"truncated":false},{"unit":"F","display":"212",...},...]}` + +- **The whole category in one call,** because the screen shows all of it. The + alternative - one `tally_eval("100 C to F")` per row - is a dozen JNI round trips and + a dozen parses per keystroke, and puts the list of units in Kotlin. +- **The value is an expression, previewed** (6.5): `6*12` in inches works, and nothing + in the session changes. +- **A row that fails carries its own `error`** and leaves the rest of the table alone. +- **The rendering budget is the session's** (`Config`, 6.1 point 3), so the converter + sets fewer digits on its own session than the calculator does. Over JNI that is + `configureDisplay`, which reads the current config, changes the two digit counts and + writes it back, so the other fields keep their values. +- `abi_version` 3. + --- +### 6.7 Save and load: a session that survives the process + +**Decided and built, for the calculator reopening as it was left (9.2).** The engine +holds the variables and `Ans`, so the app saving only what its screen shows would bring +back `x * 2` without `x`. + +```zig +export fn tally_session_save(?*Session, ?*[*]const u8, ?*usize) Status; +export fn tally_session_load(?*Session, ?[*]const u8, usize) Status; +``` + +`{"format":1,"ans":{"exact":"1/7"},"variables":[{"name":"r","value":{"bits":"3ff6a09e667f3bcd"}}]}` + +- **Exact, both tiers.** An exact value is saved as `toFractionString` writes it, every + digit; it loads through `Rational.parseFraction`, which has no length cap, because + `parse`'s 512-digit limit is right for typing and wrong for `factorial(1000)`. An + inexact value is saved as its IEEE-754 bits, so it comes back as the same float and + not a decimal near it. A reloaded session saves to the same bytes it was loaded from. +- **Not a replay.** Re-evaluating the history on launch was the alternative and is + wrong twice: `Ans * 2` depends on every answer before it, so history cannot be trimmed + without changing values, and clearing it would silently drop the variables. +- **All or nothing.** The text is read and checked completely - format, a name an + assignment could have made, no duplicates, exactly one well-formed value form - and + only then swapped in. A refused load leaves the session untouched. Unknown fields are + ignored, so a later format can add to it. +- **Deterministic:** variables are listed by name. +- **Not included:** the configuration, which is the frontend's to set. +- Tested by round trips (2^100, 1/3, sqrt(2), factorial(500), `Ans`), a byte-identical + re-save, a load that replaces rather than merges, fourteen malformed states each + leaving the session as it was, and an allocation-failure sweep over save and load. ABI + version 5. + + ## 7. CLI Design ``` @@ -2033,24 +2202,62 @@ need. --- -## 9. Android UI Design (DESIGN ONLY, NOT BUILT) +## 9. Android UI Design -Nothing in this section exists: there is no `android/` directory, no Gradle project, no -Kotlin, and the C ABI it depends on is two stubs (section 6). The screens below are the -plan. Read the struct layout screen (9.4) as doubly speculative, since the engine -feature behind it is also unbuilt. +The shell (9.1) and every screen but standard (9.3-9.7) are still DESIGN ONLY. 9.2 was +rewritten after the first build ran on a device, which is the input the rest of this +section was waiting for. -These designs are deliberately left unreconciled with what the TUI became during the -review, even where the two now disagree (9.5 has a Calculate button; the TUI dropped it -because results are live). Reconciling them on paper would be guessing twice: the useful -version of this section is the one written after there is a running app to react to. -Expect it to be rewritten from what the app teaches, not from what the TUI settled on. +That first build was a text field, the system keyboard, an `=` button and a monospace +scrollback: the TUI's prompt moved onto glass, the exact paradigm the paragraph below +rules out. It proved the boundary and was nearly unusable, and the specific ways it +failed are what 9.2 is now built against. Read the struct layout screen (9.4) as doubly +speculative, since the engine feature behind it is also unbuilt. + +The remaining designs are deliberately left unreconciled with what the TUI became during +the review, even where the two now disagree (9.5 has a Calculate button; the TUI dropped +it because results are live). Expect each to be rewritten the way 9.2 was: from a +running screen, not from what the TUI settled on. + +**The principle the built screens converged on,** written down because it was learned by +having proposals rejected, and the unbuilt screens are where it will be tested next: + +- **Nothing moves unless the user moves it.** No auto-scroll to a chosen item, no + reordering under a finger, no list that jumps when a value changes. The value line + already says what was chosen; motion the user did not cause costs them their place. + (Rejected along the way: scrolling the result list to the chosen unit; recency order + within a category.) +- **State is remembered, not guessed.** Each screen reopens exactly as it was left, and + each part of it keeps its own memory - the converter's categories each keep their + value, unit and scroll position. When something is unknown, the honest default (the + top of a list, a blank field) beats a plausible reconstruction. (Rejected: carrying a + number across categories, so 98.6 km became 98.6 kg.) +- **Stable layouts are an interface.** Fixed order, things highlighted in place rather + than removed, so that where something is on screen is worth remembering. +- **Look before designing.** The first build was the TUI on glass; every good decision + since came from reacting to something running, usually against a concrete reference + (NCalc) with taps counted. The Android app uses a fundamentally different interaction model from the TUI. Where the TUI is keyboard-driven with a prompt, Android is **touch-first with purpose-built input surfaces** for each mode. No text-cursor-in-a-prompt paradigm - instead, tappable buttons, interactive grids, and form fields. ### 9.1 Navigation & Shell -Bottom navigation bar with 4 destinations: **Standard**, **Programmer**, **Financial**, **Convert**. +**Built: a hamburger drawer, as in NCalc.** Calculator, Convert, and Settings below a +divider, under the app name and the engine version (which crossed JNI to be there). +Opened by the hamburger only; an edge swipe would fight the system back gesture and the +keys nearest the edge. Each screen also has a one-tap link to the other in its top bar, +because the drawer is two taps and switching between calculating and converting is the +common move. The app reopens on whichever was last open; Settings is never reopened. + +**Settings** holds one thing so far: the theme - follow the device (the default), +light, or dark. The choice is saved, and the system bar icons follow the app's choice +rather than the device's. The palettes are Material's baseline light and dark schemes, +not the system's dynamic colours: those were tried and, on the test device, gave the +keypad's accent bands light containers in dark mode. + +The original plan, kept for the record: a bottom navigation bar with 4 destinations, +**Standard**, **Programmer**, **Financial**, **Convert**. Programmer and Financial are +not built, and will be drawer entries when they are. ``` ┌─────────────────────────────────────────┐ @@ -2067,41 +2274,84 @@ Bottom navigation bar with 4 destinations: **Standard**, **Programmer**, **Finan ### 9.2 Standard Calculator Screen -Classic calculator layout. Expression builds at top, result previews live below it, button grid fills the bottom half. +**Decided after the first build, and built.** Touch-first: the keypad is the input, the +system keyboard is on request, and the answer is visible before `=` is pressed. The +layout follows NCalc (NCalcLibre on F-Droid), which is the reference for "what this +should feel like": a display card over a pad, a scientific block on an accent band above +the digits, and the rarer functions on a panel that slides up over the numbers. ``` -┌─────────────────────────────────────────┐ -│ Expression area (scrollable) │ -│ ┌─────────────────────────────────┐ │ -│ │ 2 × (3 + 4) │ │ ← expression in natural format -│ │ = 14 │ │ ← live result preview (dimmed) -│ └─────────────────────────────────┘ │ -│ │ -│ History peek: last 2-3 results │ -│ ┌─────────────────────────────────┐ │ -│ │ 5^2 = 25 sqrt(144) = 12│ │ -│ └─────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────┐ │ -│ │ sin │ cos │ tan │ ( │ ) │ ⌫ │ │ ← function row (swipeable for more) -│ ├─────┼─────┼─────┼─────┼─────┼───┤ │ -│ │ 7 │ 8 │ 9 │ ÷ │ ^ │ │ │ -│ ├─────┼─────┼─────┼─────┼─────┤ │ │ -│ │ 4 │ 5 │ 6 │ × │ % │ │ │ -│ ├─────┼─────┼─────┼─────┼─────┤ │ │ -│ │ 1 │ 2 │ 3 │ - │ sqrt│ │ │ -│ ├─────┼─────┼─────┼─────┼─────┤ │ │ -│ │ 0 │ . │ Ans │ + │ = │ │ │ -│ └─────┴─────┴─────┴─────┴─────┘ │ │ -└─────────────────────────────────────────┘ ++-----------------------------------------+ +| Tally History | <- the only chrome +| | +| x / 3 + 1| | <- expression, large, with a cursor; +| = 4.2255e29 (dim) | operators drawn as glyphs +|-----------------------------------------| +| sin cos tan ln log sqrt | <- scientific block, accent band +| ( ) ^ x^2 n! e | +| x A B , mod abc | +|-----------------------------------------| +| 7 8 9 CLR >` is logical in standard mode and arithmetic in programmer mode, shift amounts wrap in one and clamp in the other, and `evaluator.zig` ignores the @@ -1768,9 +1769,10 @@ what the Android app will meet first. Item 18 is a list of its own: the findings 11. ~~Literals longer than 128 characters are rejected by a fixed tokenizer buffer, and non-decimal literals are capped at 64 bits, both below what the exact tier supports.~~ Fixed by Task 5.18. -12. `engine/src/c_api.zig` has no tests and appears in no coverage report, because - no test target builds the shared library. Deferred until Phase 6 gives it a - caller; recorded here so it is a known gap rather than an oversight. +12. ~~`engine/src/c_api.zig` has no tests and appears in no coverage report, because + no test target builds the shared library.~~ Fixed by Task 6.0: two test layers now + cover it (Zig tests against `std.testing.allocator`, and a C program linked through + `include/tally.h`), and it has its own coverage report at 98.4%. 13. NFR-9.9 ends with "the exact fraction available", and no frontend ever asks for it. `Rational.toFractionString` works and is tested, but its only callers are tests: nothing in the TUI or the CLI renders `1/3` as a fraction. Wiring it @@ -1782,11 +1784,12 @@ what the Android app will meet first. Item 18 is a list of its own: the findings available" and requirements.md line 237's clipboard form are engine-side only. `Number.render` reports the fact and takes clipboard options; the TUI has yet to draw an indicator or copy anything. -15. `c_api.zig` renders nothing today, so Phase 6 has to decide how a - `Number.FormatOptions` crosses the FFI boundary. It must be parameters the - caller passes, not a default the C layer invents, or Android inherits a budget - chosen for an 80-column terminal (Task 5.17). Task 6.2 covers the bridge; this - is the display half of it. +15. ~~`c_api.zig` renders nothing today, so Phase 6 has to decide how a + `Number.FormatOptions` crosses the FFI boundary.~~ Decided and built in Task 6.0: + the display budget is five fields on `tally_config`, set on the session, and the + caller reads the defaults back rather than guessing them. A test asserts a + four-fraction-digit, separator-free configuration reaches the rendering, so the + budget provably belongs to the frontend (NFR-9.9) rather than to the C layer. 16. **The last answer should be a pseudovariable in its own right** (FR-1.6.1), and the engine does not model it as one. Task 5.20 stopped `Ans = 5` from being silently discarded, but it did so by folding `Ans` in with `pi`, `e` and `tau` @@ -1881,46 +1884,82 @@ what the Android app will meet first. Item 18 is a list of its own: the findings `-9223372036854775808.0` and `18446744073709551616.0`; and `handleKey` is a 200-line if-chain mixing global keys, mode-specific keys and zone dispatch, which reads fine today but is where a new binding will end up in the wrong order. +19. **Nothing enforces the freestanding-library rule that design 6.3 depends on.** The + Android libraries must have zero `DT_NEEDED`, zero undefined dynamic symbols and no + TLS segment, or they fail in `dlopen` before any code runs. That property was restored + by hand and is checked by hand, and three things make it likely to regress silently: + any `std` call can reach for a libc symbol on a path that never executes, `BIND_NOW` + is set so even an unreferenced PLT entry is resolved at load, and the one instance + found so far reproduced on **arm64-v8a only** - so an x86_64 emulator, which is what + CI would use, passes while phones fail. It wants a build step that parses the emitted + `.so` with `std.elf` and fails on a non-empty undefined-symbol set, per ABI, rather + than a `readelf` invocation in a shell. That step is also the natural home for the + 16 KiB `LOAD` alignment check, which is currently only a Play submission rule nobody + verifies locally. --- ## Phase 6: Android App -### Task 6.0: Finish the C ABI (PREREQUISITE, not started) +### Task 6.0: Finish the C ABI [DONE for the C layer; JNI still open] -Nothing in Phase 6 can be verified until the library answers. `c_api.zig` is the root of -`libtally.so` and has three exports: `tally_version` works, `tally_eval` and -`tally_result_free` have `// TODO: implement` bodies, and no test builds the library, so -it appears in no coverage report (open items 12 and 15). +The boundary is written, tested and cross-compiling. Three of design 6.1's four questions +are answered in code: the session is a handle, the memory model is 6.2's (session-owned +buffer, borrowed results, no `tally_result_free`), and `FormatOptions` cross as fields on +`Config`. The fourth - what the JSON carries - is answered for standard mode, programmer +mode, conversion and the units catalogue, and still open for the financial and float +views. -- Toolchain first, and all of it through mise: pin the JDK, Gradle and the Android SDK - (plus the NDK if design 6.4 picks the `jni.zig` route) in `.mise.toml`, and check each - one resolves before anything depends on it. Nothing gets installed system-wide. +**Still to do before Kotlin:** -- Decide the four open questions in design 6.1, in this order: session lifetime - (an opaque handle, or a calculator with no variables and no `Ans`), memory ownership - (design 6.2 recommends a session-owned buffer with borrowed results and no - `tally_result_free`), who supplies `FormatOptions`, and what the JSON carries. -- Confirm the ground first: DONE for the compile half. Both Android ABIs build a - dependency-free ~230KB shared object containing the whole engine, with no NDK - (design 6.2). What remains is loading it: `System.loadLibrary`, symbol resolution - from a JVM, and the Kotlin binding choice in design 6.4. -- Implement `tally_eval` and `tally_result_free` against those decisions, including - which allocator the library owns and how a caller frees what it is handed. -- Add the units and financial enumerations Android needs for its pickers, so the - category list, the unit list per category and the form field specs come from the - engine rather than being retyped in Kotlin. -- Write `engine/src/jni.zig`: the `Java_*` entry points, compiled only for Android - behind a build option (design 6.3). Keep it thin - convert, call the C ABI, copy the - result into JVM memory - because that copy is what makes the borrowed-result rule in - 6.2 safe without asking an app developer to remember it. -- Hand-write `include/tally.h` and have a test compile against the real exports, and add - `tally_abi_version()` beside `tally_version()` (design 6.3). -- Test the ABI from Zig, calling the exports the way JNI will: a test target that - builds the shared library and exercises each export, including the free path under - a failing allocator. -- Verify: `zig build test` covers `c_api.zig`, it appears in a coverage report, and a - round trip (`x = 5`, then `x * 2`) works across the boundary. +- The JNI translation layer, once design 6.4's three options are chosen between. Keep it + thin: convert, call the C ABI, copy the result into JVM memory. That copy is what makes + the borrowed-result rule safe without an app developer having to remember it. +- Financial and float-view payloads, which need form specs and a schedule shape. Better + decided against a screen than in advance. +- Toolchain through mise when the Kotlin side starts: JDK, Gradle, Android SDK, plus the + NDK if 6.4 picks the `jni.zig` route. Pinned in `.mise.toml`, each one checked to + resolve, nothing installed system-wide. +- Loading it for real: `System.loadLibrary`, symbol resolution from a JVM, and a + page-backed allocator under Android's memory pressure. The compile half is proven; none + of the runtime half is. + +**What the ABI looks like.** Eight exports in `engine/src/c_api.zig`, implementing design +6.1 and 6.2: a session handle that owns the memory, results borrowed from the session's +buffer until its next call, and no `tally_result_free` at all. Standard mode, programmer +mode, unit conversion (the `to` keyword works from standard mode exactly as it does in +the CLI, FR-4.1) and a units catalogue for pickers. Results are JSON written by +`std.json`; errors carry `engine.phrase`'s wording, so a frontend that adds its own error +table is duplicating one that exists. + +**Three lifetimes, as designed.** `Environment` on the session allocator, an arena per +call reset with `retain_capacity`, and a result buffer cleared rather than freed. A test +runs a hundred evaluations and asserts the result buffer's capacity never moves after the +first, which is the "steady state is near zero allocation" claim made concrete. + +**Two test layers, because they catch different things.** + +- Zig tests build sessions with `std.testing.allocator`, so the seam is leak-checked: a + session that forgets its arena fails here rather than on a phone. Thirteen tests cover + the session contract (`x = 2^100`, then `x + 1`, then `Ans / 2`, exact all the way + through), borrowed-result reuse, error wording, conversion, the multi-base rows, + programmer rows at a configured width and byte order, config round-tripping, the + catalogue, every malformed-call path, and the exported constructor that uses + `smp_allocator` rather than the test allocator. +- `engine/test/c_abi_test.c` links against the real library through the real + `include/tally.h` and is run by `zig build test`. This is the only layer that can catch + a header disagreeing with the library: struct field order and symbol names are + invisible from Zig. It is compiled `-std=c99 -Wall -Wextra -Werror`. + +**`zig build android`** produces `zig-out/android/{arm64-v8a,x86_64,armeabi-v7a}/libtally.so`, +which is the layout Gradle's `jniLibs` expects, so packaging stays a copy and Gradle +never invokes Zig. ReleaseSmall always: ~300KB per ABI, and every one has zero +`DT_NEEDED` entries. A debug build is twenty times that and has no business in an APK. + +- Verify: 956 tests pass plus the C program, fmt and zlint clean, ASCII clean. + Coverage adds a fourth report: engine 99.42%, CLI 98.87%, TUI 98.41%, ABI 98.41%, with + the ABI's five uncovered lines all out-of-memory paths. `zig build` still installs the + host library and now the header with it. ### Task 6.1: Set up Android project structure - Create `android/` directory with Gradle project (Kotlin DSL) @@ -1954,6 +1993,133 @@ it appears in no coverage report (open items 12 and 15). - Long-press `=` to copy result, tap history items to recall - Verify: can perform calculations, see results, scroll history +### Task 6.1: Set up Android project structure [DONE, unverified on a device] + +`android/` is a Gradle project with one module: Compose, Material 3, minSdk 26, +`namespace`/`applicationId` `dev.lerch.tally`. `jniLibs.srcDirs` points at +`zig-out/android`, so `zig build android` output is packaged with no copy step and Gradle +never invokes Zig. `abiFilters` lists exactly the three ABIs that step produces. + +`zig build android` now roots at `jni.zig` rather than `c_api.zig`, so the Android library +carries both the `Java_*` entry points and the C ABI, and sets +`link_z_max_page_size = 16384`: Android 15's 16KB pages are a Play requirement, and the +default would load fine on a 4KB device and be rejected at submission. + +The toolchain is pinned in `.mise.toml` (`java = "temurin-21"`, `gradle = "8.14.5"`, +`android-sdk = "23.0"`), and `mise install` is necessary but not sufficient: it provides +`sdkmanager` but no SDK to build against, and the packages `sdkmanager` downloads land +inside the mise install directory where mise does not track them. Four mise tasks close +that gap - `android-sdk`, `android-emulator`, `android-avd`, `android-run` - so every step +is one command and the versioned packages are pinned. `platform-tools` and `emulator` have +no version in their package ids and are the one thing that stays unpinned. No NDK, by +design. The whole procedure, including deploying over USB and over wireless adb, is +`android/SETUP.md`. + +The emulator image worth using is `system-images;android-35;google_apis_ps16k;x86_64`, +because its 16 KiB pages are what `link_z_max_page_size` is for. The AVD lives in +`.tmp/avd` via `ANDROID_AVD_HOME` rather than `~/.android`, which keeps it out of the home +directory and also routes around a dangling `~/.android/avd` symlink on this machine that +makes `avdmanager create` fail with nothing but a path in the error. + +- Verified: `gradle assembleDebug` builds a debug APK with all three `libtally.so` files + packaged (`lib/arm64-v8a`, `lib/x86_64`, `lib/armeabi-v7a`). AGP 8.7.3, Kotlin 2.0.21 + with the Compose plugin, and Compose BOM 2024.10.01 all resolve against Gradle 8.14.5. + One compile error was found and fixed on the way: Material 3's `TopAppBar` is still an + experimental API and needs an opt-in. +- Verified on an emulator: the app installs, launches, and evaluates. The 10 instrumented + tests pass (`gradle connectedAndroidTest`). Getting there found a real loading bug that + no host test could have caught - see design 6.3 - because the first APK crashed in + `dlopen` on a missing `__tls_get_addr`. +- Not verified: the Gradle wrapper is not committed, and nothing has run on a physical + device. arm64-v8a is the gap that matters, since it is the ABI phones use and the one + where the page-size bug appeared. + +### Task 6.2: Implement JNI bridge [DONE for the skeleton] + +`engine/src/jni.zig`, the only Android-specific code in the tree, hand-written against a +function table declared by index because the NDK is not in the picture (design 6.4). Seven +entry points: session create and free, eval, unit catalogue, version, ABI version, and +`JNI_OnLoad`, which proves the table against the running JVM and fails +`System.loadLibrary` if it does not behave. + +Kotlin side: `TallyEngine` (the `external fun`s and an ABI check), `TallySession` (an +`AutoCloseable` handle, so a use-after-close is a Kotlin exception rather than a native +crash), and `TallyResult`, which parses the JSON with `org.json` - Android ships it, and +the payload is small enough that a serialization dependency would buy nothing. + +Kotlin does no arithmetic, formats no numbers and words no errors. `MultiBaseResult` and +`StructLayoutResult`, which this task originally told us to deserialize, do not exist: the +first was deliberately dropped (design 2.2) and the second belongs to unbuilt FR-3. + +- Verify: 965 tests pass on the host, including seven JNI tests against a synthetic + function table, and `jni.zig` is at 100% line coverage in its own report. +- And the part that was supposed to need a device does not: `zig build jvm-test` proves + the four function-table indices against a real JVM on the desktop, because JNI's table + is fixed by the specification and not by the platform. `System.loadLibrary` runs + `JNI_OnLoad`, then every entry point is driven from Java. Shifting one index by a slot + makes the step fail, so the check is known to work rather than assumed to. + +### Task 6.3: Implement standard calculator screen [DONE; redesigned after the first build] + +**The first version** was an expression field, the system keyboard, an `=` button and a +monospace scrollback. It proved the boundary end to end on a device and was nearly +unusable: the TUI's prompt moved onto glass, which section 9 had ruled out in so many +words. It is recorded here because the redesign is a list of answers to its failures. + +**What it is now** is design 9.2: an NCalc-style pad with no system keyboard by default, +a live result from the engine's new preview (design 6.5), `=` committing and continuing +from `Ans`, variable keys whose long-press stores into them, an `abc` key for the system +keyboard, a slide-up panel for the rarer functions, and history on its own screen. Kotlin +in `MainActivity.kt` (screen, display, history), `Keypad.kt` (keys and layout) and +`TallyViewModel.kt` (state, and every engine call on one dedicated thread). + +The engine half, done first and test-driven: +- `engine.previewStringInfo`, taking a `*const Environment`. Assignment is a statement + (Task 5.19), so `evalStringInfo` now unwraps the root assignment itself and the + recursive evaluator takes the environment as `const` - it can no longer store + anything, and a preview that mutates the session is a compile error, not a test. Five + evaluator tests under `std.testing.allocator`, including a hand-built nested + assignment that is refused. +- `tally_preview` in the C ABI, same signature as `tally_eval`, sharing its argument + checks; `abi_version` 2, `TALLY_ABI_VERSION 2u`, `EXPECTED_ABI_VERSION = 2`. Three + `c_api` tests, a C ABI check through the real header, a JNI test against the synthetic + table, and `jvm-test` against a real JVM. +- A side effect worth having: a failed copy no longer leaves an assignment half-made, + because the variable is stored only after the value has been copied out. + +- Verified on an emulator (Android 15, x86_64, 16 KiB pages), by driving the pad: + `2+3*4` previews `= 14` before `=`; `=` shows `14` with the expression above it; `*` + then `2` reads `Ans*2` and previews `= 28`; `abc` hides the pad and opens an input + connection, `r = 5` previews `= 5`, and Enter commits and brings the pad back; the + panel slides over the digits and back; long-press `A` after `77` stores it, and `A+2` + previews `= 79`; History lists entries newest first, tapping one recalls it, and + system back returns to the calculator; `2^100` shows all 31 digits on one line. +- 11 instrumented tests pass, including the new preview test. 974 host tests pass. +- One bug found by driving it and fixed: the field wraps, so a keyboard's Enter arrived + as a newline instead of as Done. A newline from the keyboard now means `=`. +- Not verified: a physical device; a real on-screen keyboard (the emulator's IME showed + its hardware-keyboard toolbar, so the inset-based "keyboard dismissed, bring the pad + back" path was not exercised - Enter was); TalkBack, though every symbol key carries a + spoken label; and dark mode, which follows the system but was not looked at. + +**Persistence, after the converter showed what it should feel like:** the calculator +was the screen that forgot - expression, answer, history, variables and `Ans` all gone +when the process died. Now all of it comes back (design 9.2, 6.7). +- Engine: `tally_session_save` / `tally_session_load`, exact for both tiers (fraction + text with every digit, f64 bits), all-or-nothing, ABI 5. `Rational.parseFraction` is + the uncapped inverse of `toFractionString`. Nine Zig tests including two + allocation-failure sweeps; a C round trip through the header; JNI against the + synthetic table and a real JVM. 992 host tests. +- App: `CalcState.kt`, plain Kotlin with six JVM tests (22 in all) - history keeps the + engine's own JSON per entry, so a restored entry is parsed exactly as a fresh one; + the newest 500 are kept; damaged lines are dropped, not guessed at; the cursor is + clamped into the text. One more instrumented test (13). +- Verified on the emulator: store 7 in A, `A x 6 =`, leave `2+` typed, force-stop in + the foreground, relaunch: `2+` is back, `2+Ans` previews 44, `A` is 7, history holds + all four entries. +- Decided: clearing history keeps variables and `Ans`. Replaying history on launch was + the alternative, and rejected - see design 6.7. + ### Task 6.4: Implement programmer mode screen - Compose UI: segmented control for bit width (8/16/32/64) - Value display cards: DEC, HEX, OCT, BIN, signed/unsigned (tappable to switch primary input base) @@ -1982,18 +2148,74 @@ it appears in no coverage report (open items 12 and 15). - Long-press result to copy - Verify: all financial calculations produce correct results, formula display works -### Task 6.7: Implement unit conversion screen -- Compose UI: scrollable category chip row at top -- Large editable number input (top), from-unit selector dropdown -- Swap button (animated rotation) between from/to -- Live result display (bottom), to-unit selector dropdown -- Numeric keypad (no expression input needed - just digits, decimal, sign) -- Unit selectors open searchable bottom sheets -- Conversion updates live as user types (no "calculate" button) -- Verify: live conversion works, category switching filters units, swap works +### Task 6.7: Implement unit conversion screen [DONE] + +Design 9.6, built as a deliberate departure from NCalc, whose converter costs Unit +Converter, then Temperature, then the input unit, before a digit - and starts there on +every launch. Here the app reopens on the converter exactly as it was left, and the +common case is: open, type, read. + +- Engine: `tally_convert` (design 6.6) returns a value in every unit of its category in + one call, previewed so nothing is stored; `abi_version` 3. Four `c_api` tests (every + temperature unit exact from `100 C`, an expression value that stores nothing, an + inexact angle row, errors and malformed calls), a C ABI check through the header, JNI + tests for `convert` and `configureDisplay` against the synthetic table, and `jvm-test`. + 980 host tests; the three Android libraries still have zero undefined symbols. +- Kotlin: `ConvertScreen.kt`, `ConvertViewModel.kt` (state, its own session and + thread), `Units.kt` (the catalogue and table parsed), `AppPrefs.kt` (theme, last + screen, converter state, recents). One instrumented test added; 12 pass. +- Verified on the emulator by driving it: relaunch after a force-stop opens on Convert + with the saved value, unit and dark theme; tapping the F row then typing `98.6` gives + 37 C, exact; the km chip carries the number across categories; 98.6 km, tap mi, tap + km is exactly 98.6 (it was 98.6000000000 and 98,599,999,999,998.1824 nm before the + source unit was kept separate from the shown one - a bug caught by looking); the + picker shows every unit grouped by category. +- Not verified: a physical device, TalkBack, and the light theme on the converter after + the palette change. + +Also in this pass, from the same request: the calculator panel's labels are now +"functions" and "numbers"; a hamburger drawer (Calculator, Convert, Settings) as in +NCalc; a theme setting - follow the device by default, or light or dark - saved and +applied to the system bars too. + +**Redesigned after first use (design 9.6):** the top row became every category in +most-recently-used order behind a fixed search tile; each category remembers its own +value and units; the result list is in fixed table order with the shown unit +highlighted in place; the unit button opens the category's units only. The rules moved +into `Converter.kt`, plain Kotlin with 12 JVM unit tests (`gradle testDebugUnitTest`, no +device) - the first Kotlin tests that do not need one. A mutation check (reinstating the +old backspace bug) fails the suite, so they test what they claim. That old bug: after +tapping a row, backspace edited the typed value read as the shown unit - 98.6 km, tap +mi, backspace gave `98.` miles. +- Verified on the emulator, driven by label: 98.6 F and 180 mi each restored by one tap + on their category; tapping the C row highlights it in place with nothing moving; the + Temperature chip then reads `37 C`; search for `knot` finds Speed > kn and lands on + 1 kn; a force-stop restores all of it. 12 instrumented tests pass. +- Migrates the single value an older build saved (unit tested; not exercised on a + device, since the instrumented run uninstalls the app first). +- Follow-up from first use on the phone: the unit button became a dropdown (the + separate screen was the wrong weight for 4 to 15 units), and every unit gained a + required engine-side display name (`label` in `units.zig`, in the catalogue at ABI 4), + replacing the Kotlin guess that produced "Meterpersecond". A test in `units.zig` holds + every label to non-empty, capitalised, printable ASCII and unique within its + category; 983 host tests, 12 JVM, 12 instrumented. +- Then per-category scroll position (design 9.6): the unit in the top row plus pixel + offset, in the category's saved state, optional on load so older saves still work. + Nothing but the user's finger moves the list - auto-scrolling to the chosen unit was + proposed and rejected, because it fights reading down the column. Four more JVM tests + (16), with a mutation check: reinstating the old `edited`, which rebuilt the memory + from scratch, drops the position on the first keystroke and fails the suite. On the + emulator the first fully visible row came back at the same pixel after a dropdown + choice, typing, a category switch, and a background plus force-stop. ### Task 6.8: Android polish -- Material 3 theming (dynamic color on Android 12+) +- Material 3 theming: DONE with Task 6.7 as light, dark and follow-the-device, on + Material's baseline palettes. Dynamic colour (Android 12+) was tried and dropped: it + made the keypad's bands light in dark mode. Open item 17 (TUI theming) is separate. +- Launcher icon: DONE. Tally marks - four strokes and the closing diagonal - on the + baseline primary purple, as an adaptive vector icon (minSdk 26, so no bitmaps) with a + monochrome layer for Android 13+ themed icons. Artwork inside the 66dp safe circle. + Installed on a Galaxy S23+; seen on the launcher by the user, not by me. - Tablet layout: two-pane (input left, results/visualization right) - Landscape orientation: adapted layouts (wider bit grid, side-by-side panels) - Haptic feedback on bit toggle and button presses