refactor: make the 40 interpolated UI strings format strings, and assert the argument order

Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to
${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call
passing the expression. 49 call sites. Literals in composables go 76 -> 39;
`stringResource` goes 374 -> 424.

**A silent bug in the previous commit's extractor, found by this one.** Imports were
tested with `statement in source`, and the generated accessors are named after their
strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of
`...resources.translate_into_which_dialect`. The substring test decided the import was
already there, and the compiler reported "Unresolved reference 'translate'" in a file
whose imports looked complete. Both extractors now match whole lines, and the helper
carries the explanation.

**Four filters, each earned by something the dry run got wrong.**

*A template that is only interpolation has nothing to translate.* `Text("$name")` would
have become a resource holding `%1$s` -- longer, slower, and no more localisable than the
code it replaced.

*A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a
separator. The test has to be on the format string rather than on the literal halves: a
template opening with an interpolation leaves the first part empty and the second starting
with the separating space, which makes "%1$s Key packages" look like a fragment when it is
a whole label.

*`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the
six visible characters of the escape. They are decoded into the resource, which is UTF-8
and can hold `·` directly. `\\n` is **not** decoded, because
StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an
XML value would be reflowed by the parser.

*A term of a `+` concatenation is still not a string.* Same rule as the plain extractor.

**Three copy problems surfaced only here, because interpolated strings had never been
checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a
literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had
`"replying To ${…}"`. And a third instance of the old product name, in
`"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather
than a one-off: title case inside a template is still unchecked, and there are 83
concatenation fragments left where it could hide.

**Two new assertions, on the two things a compiler cannot see.** Argument *order* is
decided by where each `${…}` sat, and a transposition compiles and reads plausibly --
"Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument
string are asserted end to end. The three-argument one doubles as the check that `·`
was decoded rather than passed through.

**What is deliberately left.** 83 literals that are terms of a `+` concatenation.
Reassembling `"a " + x + " b"` into one format string means deciding what the whole
sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` --
which want a real plural resource rather than a format argument, and that is an API choice
rather than a rewrite. `m3-extract-formatted.py --remaining` lists them.

**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349.
`:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on
emulator-5554 through onboarding, the message list and a chat room with its text intact.
`m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 01:45:35 +02:00
parent 419504c982
commit 2117e22d48
26 changed files with 462 additions and 76 deletions

View File

@@ -608,35 +608,51 @@ neither an outline nor a checkmark, and adding one is component work.
### Phase 4 — text the system can translate
**Why here.** It is the last phase that touches every file, and doing it after
Phase 2 means one pass over each file instead of two. It must precede Phase 6:
RTL is a breakpoint concern too, and there is no point testing a mirrored layout
against 334 English literals.
**Why here.** It is the last phase that touches every file, and doing it after phase 2
means one pass over each file instead of two. It must precede phase 6: RTL is a breakpoint
concern too, and there is no point testing a mirrored layout against 334 English literals.
**Work.**
**Built.** Three commits.
1. **Externalise all 334 strings** into `composeResources/values/strings.xml`,
organised by screen. Clear out the inherited Phoenix wallet strings that
nothing references, and settle `app_name`.
2. **Sentence case, everywhere.** "Edit profile", "Create profile", "New chat",
"Sign in", "Leave group", "Key package management", "Publish new key
package". Roughly 45 strings. Product names stay capitalised — which requires
settling on one: Torch, Mantra or Machankura.
3. **Rewrite the destructive confirmations** to state consequences plainly.
"Delete group" and "Leave group" currently offer a label and nothing else;
the style guide wants the outcome and whether it can be undone.
4. **Alt text for meaningful images** — profile avatars, QR codes, artifact
images — following the Phase 3 triage rule.
5. **Spell out abbreviations** in user-facing text. Protocol terms that are
genuinely the domain (npub, NIP-05, relay) stay; incidental shortenings go.
6. Verify RTL by mirroring: the codebase has no `left`/`right` modifiers, so
this should be confirmation rather than repair.
1. **Sentence case, 100 occurrences across 60 strings.** Two passes, and the second is
the instructive one: the first pattern required every word after the first to be
capitalised, so anything with an article survived — "Invite a Friend" was still on the
app's first screen after the audit reported zero — and it read one line at a time, so a
`Text(` whose literal sat on the next line was invisible.
**Done when** the audit reports fewer than ten string literals in composables
(test data and previews), and no user-facing string uses title case.
Sample data is deliberately left in title case: "Steve Biko", "To Kill a Mockingbird".
Those are a person and a book.
**Risk:** low, high volume. Sentence-casing is the part most likely to draw
disagreement — settle the product name first, in one decision.
2. **251 plain strings into the catalogue**, 315 call sites. The extractor took four
attempts and each failure is recorded in `docs/scripts/m3-extract-strings.py`: a bare
`text = "…"` is not a Compose string (it rewrote a data class), a regex over quote
pairs is not a Kotlin lexer (it lifted `"chunk"` out of `"${if (n == 1) "chunk" …}"`),
and a fragment of a `+` concatenation is not a translatable unit.
3. **40 interpolated strings as format strings**, 49 call sites — `${expr}` to `%1$s`,
passed as arguments.
**Compose Resources is not aapt, and a test caught that before a device did.** The first
extraction escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither: `getString` returned `Don\'t sign`,
backslash included, across 30-odd strings. Escape handling is *partial* rather than
absent — `\n` **is** processed — so there is no family rule to lean on.
`StringCatalogueJvmTest` asserts each escape it depends on.
**Done:** literals in composables 334 → 39, `stringResource` 0 → 424, title case 0. Plus
1101 inherited phoenix strings deleted (nothing referenced them), the product settled on
one name, and the two destructive actions now state their consequences — read out of the
repository rather than guessed, because "Delete group" with no qualifier invites the
belief that the messages are gone from the relays, which is the opposite of true.
**Left for later, and why it is not a script's job.** 83 literals that are terms of a `+`
concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what
the whole sentence is, and several of them are pluralisations —
`(if (n == 2) "event" else "events")` — which want a real plural resource rather than a
format argument. `m3-extract-formatted.py --remaining` lists them.
`UserAgent.APP_NAME` still says "Torch". It goes on the wire to relay operators, so it is
a network identity question rather than a content one.
---