2026-06-15 14:39:46 +02:00
<resources >
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue
Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is
unambiguous: "All text, including titles, headings, labels, menu items, navigation
components, app bars, and buttons should use sentence-style capitalization. ... Don't use
title case capitalization." The tree was title case throughout.
**100 occurrences across 60 distinct strings**, in two passes, and the second pass is the
interesting one.
The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or
`contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter",
"Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported
zero and the app still had "Invite a Friend" on its first screen.
Two holes. The pattern required every word after the first to be capitalised, so anything
with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact",
"Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the
next line was invisible. A whole-file scan allowing lowercase articles found 19 more
strings, 27 occurrences.
**Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank
Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and
titles of works, and title case is how those are written. The first audit swept them up
and reported 67 offenders where the real number was 41, which is the kind of number that
teaches a reader to ignore the tool.
Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is
Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is
written for whoever is reading logcat.
**Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in
with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you
sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became
"Lightning payment": M3's rule for a description is to name the purpose rather than the
picture, and "bolt" is the picture.
**The product has one name now, and it is Mantra.** The launcher label, the desktop window
title, the landing screen and the package all said Mantra; the home screen's app bar said
"Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed.
`UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire
to relay operators, so it is a network identity question rather than a content one, and a
comment at the call site says so.
**The two destructive actions now say what they do.** "Leave group" and "Delete group" are
`TextButton`s that fire immediately, with no confirmation step and nothing stating the
consequence. M3: "Tell users what will happen if they take an action and how they can undo
it."
Read out of the repository rather than guessed, because saying the wrong thing about a
destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and
posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and
nothing else. So: "Posts a line to the room saying you left, and lets you delete it from
this device afterwards", and "Removes the room from this device. The messages stay on the
relays and with the other members." The second matters most -- a button labelled "Delete
group" with no qualifier invites the belief that the messages are gone, which is the
opposite of true.
**1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix
wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts
-- and **nothing referenced any of it**. The tree's only two `stringResource` calls are
both commented out, and one of them names an `R.string`, which does not exist in a Compose
Multiplatform resource set at all. Keeping them made the file look like the app's
catalogue while the app's actual 332 strings sat in composables. It now holds `app_name`
and a note about what happens next.
A trap for the next person, recorded in the file: the compose resources plugin reports an
XML comment containing a double hyphen only as "XML file ... is not valid. Check the file
content." XML forbids `--` inside comments, and this commit hit it while writing that
note.
**The audit's check is now a script, for the reason the second pass exists.**
`docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by
name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three
ways and reported success anyway, which is worse than not checking.
**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug
apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals
themselves are the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
<!--
The app's own string catalogue. Nearly empty, for now.
This file previously held 1101 strings inherited from the phoenix wallet fork
(notification channel titles, electrum server settings, swap timeouts), and
app_name was "Machankura". Nothing referenced any of them: the only two
stringResource calls in the tree were both commented out, and one of them named an
R.string, which does not exist in a Compose Multiplatform resource set at all.
Keeping 1101 dead strings made this look like the app's catalogue while the app's
actual 334 strings sat inside composables.
The launcher label is a separate resource, in androidMain/res/values/strings.xml,
feat(groups): the queue of a list the group curates, read off the relays it named
`930d37c8` gave a group the schema: the definition of a list, signed by the room's
own key, for strangers to answer. This is the answers. Under every schema card on the
group's screen there is now a "View suggestions" button, and it opens the list's
*queue* -- every kind 31888 anyone has published in reply to the schema's coordinate,
newest first, whoever signed it, with a mark on each one the group has already taken
up into the list. It is the second half of the curated-list NIP in this app, the
read side of it: suggestions (31888) and canonical entries (31890) are now read and
checked here. They are still not written; that is the third half.
The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that
site's one list: one row per suggestion event rather than one per film, so that a
reader can see what is waiting before anything is added and whether the curator has
taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the
reference for everything the screen decides -- one row per event, the newest
version per coordinate, "Curated" where a canonical entry shares the `d`, and a
timer standing in for an answer that never comes.
**The button is behind no gate, and it is per card.** Every other button in the
identity block is hidden from a member holding no share of the key, because each of
them proposes a signature by the group. The queue is the one thing in the block the
group did not write. A schema exists to be answered by strangers, and reading the
answers takes no share of anything, so the button is there for whoever is looking
-- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a
schema and finds it. It sits under its own card rather than once for the section,
in the same `item {}` as the card, because each list has its own queue and a button
between two cards would say nothing about which one it opened.
**The protocol is ported rule for rule, and the fixtures are the NIP's own.**
`nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`,
`verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind
is the one the schema names; every required field is present; no field without
`repeat` appears twice; every value matches its field's type and config; every
`require-any` group is met; the `a` root names this schema and no other; and the
author is somebody the visibility admits. A canonical entry gets two rules on top --
its author is the schema's author, because curation is the one power a schema does
not delegate, and a source pointer, when there is one, is a well-formed suggestion
coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest`
takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's
example, and the curator's sign-off on it, and checks both against the bitcoin.mov
schema from the same document; then the doc's own minimum suggestion, which has an
IMDb link and no watch link and satisfies `require-any` that way; then every
rejection the doc lists and says why it matters -- an unknown type, a year outside
1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a
`d` with a space in it -- plus the reply rules, the repeat rule, the closed-list
rule and the two canonical rules.
**Rejected, not repaired, and read by field.** The NIP says a client MUST verify an
entry against its schema and MUST reject one that does not satisfy it rather than
showing it with the bad parts blanked out; relays carry malformed, partial and
hostile events from other applications. So `suggestion` and `canonical` return the
entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the
reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema.
problems` is an enum rather than a sentence -- so that a form later can show them
inline. What comes back is a `CuratedEntry` keyed by the schema's *field names*
rather than by tag, because a field is what a form and a screen know an entry by and
the tag is only where it was kept: the two `r` tags of the example land on
`watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body
on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no
field of the NIP's schema and are not in the reading -- "tags the schema does not
define MAY be present and MUST be ignored".
**A pattern this platform cannot compile counts as not matched.** The reference
builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on
a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring
an uncompilable pattern would accept entries the site refuses; refusing them is the
conservative reading, and it is documented at the check.
**A coordinate splits on the first two colons only.** A `d` may itself contain
colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses --
so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`,
and the test pins the identifier surviving its own colon and the pubkey being
lowercased on the way through.
**The queue is a reading over stored rows, with three things to close and one to
add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the
coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the
queue out of it. Closed: an event that does not satisfy the schema; a kind 31890
signed by anybody but the group, which is somebody else's list and marks nothing;
and an older version of an entry its author has since replaced, since both kinds
are addressable and different relays hold different latest versions, so the merge
that realises an edit happens here, newest per `kind:pubkey:d` with the event id
breaking ties. Added: `isCurated`, true where a canonical entry the group signed
shares the suggestion's identifier -- the coordinate both land on, and the
reference page's rule. Newest first, because that is the order a queue is read in.
**A stranger is named by their profile, and by their key until one arrives.** The
indexer writes a placeholder profile named "LOADING..." the moment a pubkey is
first seen, and a queue full of that would be a queue of nobody.
`suggesterName` reads the profile only when it is a real one -- `createdAt` past
`GENESIS_AT` -- and otherwise the short key, which is at least stable and
distinguishing. The row's avatar is tinted from the key like every avatar here, so
two suggestions by one stranger read as one stranger.
**Read from where the schema says, and the screen says where.** The schema's
`relay` tags are signed into the event for exactly this: a client that finds the
list anywhere knows where its replies live and cannot be sent elsewhere by an
unsigned config. A schema naming none -- the NIP allows it and lets a client pick
-- is read from the group's general relay list, the *read* relays of it, since this
is a read; and failing an agreed, non-empty one, from this build's own relay, the
same fallback the schema editor seeds from. Spellings are normalised so one relay
written two ways is one request. The header names the relays asked, because a
queue read from the wrong relays and a list nobody has written to look exactly
alike from here, and a member should be able to tell them apart.
**Nothing here talks to a relay; it is a pull like every other one-off read.**
`CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay
for the sync pump to drain, carrying the NIP's two queries as one request's two
filters -- everything anyone published in reply to the coordinate, and what the
*group* published in reply to it, the second scoped by `authors` to spare the relay
the work `queueOf` does regardless -- and watches the local table for what comes
back. That watch needed a way to observe an arbitrary NIP-01 filter, which the
repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes
and falls back to text notes for the rest, and none of its shapes is "these kinds
with this `a` tag". `observeNostrEventsMatching` applies the filter whole through
`NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is
anchored and escaped rather than a substring scan; the DAO method behind it names
its observed tables explicitly, the events and the profiles joined onto them, so a
row whose author's kind 0 arrives after the row does re-emits with the name filled
in. What the screen shows is therefore what this device has *stored* for the
coordinate -- which is also what it shows with no network at all, and what it
showed last time until the relays answer again. `NostrEvent.toEvent` is the small
conversion the reading needs to hand a stored row to a verifier written against
the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the
group's own.
**"Still looking" is bounded by time, because nothing else bounds it.** The pump
marks a request sent when the REQ goes out and processed when an event comes back.
A relay holding nothing for the coordinate answers with an EOSE the pump does not
record, so there is no signal for "asked and empty", and a screen that showed the
local table's first, empty read would call every list empty for the second before
its rows arrived -- which teaches a member not to believe it. So the queue is
"being asked" from the request going out until either something lands or ten
seconds pass, and only after that is an empty queue empty. The reference store does
the same with a six-second timer; ten here because the request queues behind the
pump's four subscription slots before it goes out. Rows arriving end it early, a
late arrival after it is still shown, and `withQueue` -- the one state merge --
pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers
"Ask again", which re-queues the same requests: the one thing the watch cannot do
on its own is make more arrive.
**The row shows what an entry is, not where to find it.** The screen is driven by
a schema it has never seen, so what goes on a row is a decision rather than a
layout: the title as the headline, since every list has one; a glance line of the
form fields that say what the entry is, as `Label: value`, because a bare
"2014 · en" says nothing to somebody who does not know the list's fields; the body
where the list has one; and who suggested it, when. Links are left to the sheet --
a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it
a member might act on: it says the group already took this up, and re-curating it
would revise the entry rather than add one.
**The sheet has the whole entry and the event it came as.** Every field the entry
answered, labelled as the schema labels it and in the schema's order, so it reads
as the form would have; the title not repeated, since it is the heading; tokens and
links monospaced because those are compared and copied rather than read. Then the
signed event, byte for byte, with a copy button -- the way the key state sheet
shows the group's own statement and for the same reason: a prettier rendering is a
different string to the one whose id was hashed. It is also the thing a canonical
entry is built from, when that half arrives.
**Four states, and the transition between them.** Loading while the room and the
schema are read; an error, with nothing to retry, for a schema this device does
not hold, since the identifier came from a navigation argument and reading it
again fails the same way; and a loaded state that is either the rows, or looking,
or empty with something to do. `ScreenStateTransition` wraps the `when`, which is
the Scaffold's whole content.
**The audit's proper-noun list grows by two films.** The preview suggests *The Rise
and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and
`m3-title-case.py` excludes sample data by name rather than by pattern.
**Still nothing has reached a relay, and it bites once more.** Group-signed events
are not yet published, so a group's schema has not been where a suggester could
find it, and its queue is empty until it has. The screen reads the coordinate
`31889:<group>:<d>` all the same and shows whatever a relay holds for it; the
suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are
correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at
the reading.
32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion
and canonical entry against the NIP's schema: the reading by field, the minimum
suggestion, the source pointer and its absence, curation not being delegated, a
malformed source, the wrong kind, the reply rules, a missing required field, the
repeat rule both ways, every value rule the doc lists, `require-any`, the closed
and private lists, `checkValue` on durations, patterns and https, and the
coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows:
verified suggestions newest first with the broken, the foreign and the wrong-kind
ones dropped, an edit collapsing to its newest version, the curated mark placed by
the group's canonical entry and not a stranger's, and the naming of a suggester
with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest`
(6) covers the relay order and the read-only filter, one request per relay carrying
both queries and the watch covering both kinds, the three outcomes of `withQueue`,
an `initiate` run end to end against fakes -- the relays asked, the filter watched,
the rows shown, the looking ended -- and a missing schema being an error rather
than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header,
the row's glance line with the link kept off it, the mark on the right row and only
that row, looking against empty, and the sheet with its fields, its link and its
event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the
button under each card in turn and in front of a member with no share.
1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.
Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@1e52fc8f25e29017f1acb72697166ecdfe99f320
2026-09-12 12:54:34 +02:00
which is what AndroidManifest.xml points at. Both now say "Mantra".
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue
Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is
unambiguous: "All text, including titles, headings, labels, menu items, navigation
components, app bars, and buttons should use sentence-style capitalization. ... Don't use
title case capitalization." The tree was title case throughout.
**100 occurrences across 60 distinct strings**, in two passes, and the second pass is the
interesting one.
The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or
`contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter",
"Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported
zero and the app still had "Invite a Friend" on its first screen.
Two holes. The pattern required every word after the first to be capitalised, so anything
with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact",
"Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the
next line was invisible. A whole-file scan allowing lowercase articles found 19 more
strings, 27 occurrences.
**Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank
Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and
titles of works, and title case is how those are written. The first audit swept them up
and reported 67 offenders where the real number was 41, which is the kind of number that
teaches a reader to ignore the tool.
Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is
Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is
written for whoever is reading logcat.
**Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in
with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you
sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became
"Lightning payment": M3's rule for a description is to name the purpose rather than the
picture, and "bolt" is the picture.
**The product has one name now, and it is Mantra.** The launcher label, the desktop window
title, the landing screen and the package all said Mantra; the home screen's app bar said
"Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed.
`UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire
to relay operators, so it is a network identity question rather than a content one, and a
comment at the call site says so.
**The two destructive actions now say what they do.** "Leave group" and "Delete group" are
`TextButton`s that fire immediately, with no confirmation step and nothing stating the
consequence. M3: "Tell users what will happen if they take an action and how they can undo
it."
Read out of the repository rather than guessed, because saying the wrong thing about a
destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and
posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and
nothing else. So: "Posts a line to the room saying you left, and lets you delete it from
this device afterwards", and "Removes the room from this device. The messages stay on the
relays and with the other members." The second matters most -- a button labelled "Delete
group" with no qualifier invites the belief that the messages are gone, which is the
opposite of true.
**1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix
wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts
-- and **nothing referenced any of it**. The tree's only two `stringResource` calls are
both commented out, and one of them names an `R.string`, which does not exist in a Compose
Multiplatform resource set at all. Keeping them made the file look like the app's
catalogue while the app's actual 332 strings sat in composables. It now holds `app_name`
and a note about what happens next.
A trap for the next person, recorded in the file: the compose resources plugin reports an
XML comment containing a double hyphen only as "XML file ... is not valid. Check the file
content." XML forbids `--` inside comments, and this commit hit it while writing that
note.
**The audit's check is now a script, for the reason the second pass exists.**
`docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by
name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three
ways and reported success anyway, which is worse than not checking.
**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug
apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals
themselves are the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
Externalising the 334 is the rest of phase 4 in
docs/material-design-conformance.md. They land here.
Note for whoever edits this: XML forbids a double hyphen inside a comment, so use
an em dash or a comma. The compose resources plugin reports that only as
"XML file ... is not valid. Check the file content."
-->
<string name= "app_name" > Mantra</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "add_a_dialect_the_group_can_translate_into" > Add a dialect the group can translate into</string>
<string name= "add_artifact_to_library" > Add artifact to library</string>
<string name= "add_artifact_to_the_group_library" > Add artifact to the group library</string>
<string name= "add_chapter" > Add chapter</string>
<string name= "add_dialect" > Add dialect</string>
<string name= "add_to_group" > Add to group</string>
<string name= "add_translation" > Add translation</string>
<string name= "after_this_there_is_no_turning_back" > After this there is no turning back.</string>
<string name= "all_broadcasts_are_queued_so_that_we_can" > All broadcasts are queued so that we can manage data usage on metered connections.</string>
<string name= "any_amount" > Any amount</string>
<string name= "article" > ARTICLE</string>
<string name= "artifact_detail" > Artifact detail</string>
<string name= "as_long_as_you_control_your_keys_there_can" > As long as you control your keys there can be no dispute about who YOU actually is.</string>
<string name= "back" > Back</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "backup_confirmation" > Backup confirmation</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "be_sure_to_keep_this_nsec_safe" > Be sure to keep this nsec safe.</string>
<string name= "be_the_first_to_comment" > Be the first to comment.</string>
<string name= "bio" > Bio</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "bip39_seed_with_the_standard_bip84" > BIP39 seed with the standard BIP84 derivation path. The profile\'s nostr key comes off the same seed, so these 12 words restore both.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "block_user" > Block user</string>
<string name= "cancel" > Cancel</string>
<string name= "change_account" > Change account</string>
<string name= "chapter_detail" > Chapter detail</string>
<string name= "chapter_name" > Chapter name</string>
<string name= "chapter_translation" > Chapter translation</string>
<string name= "chapters" > Chapters</string>
<string name= "choose_who_to_chat_with" > Choose who to chat with</string>
<string name= "close" > Close</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "cloud_backup" > Cloud backup</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "copy_url" > Copy URL</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "could_not_unlock_your_phrase_please_try" > Could not unlock your phrase. Please try again.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "country" > Country</string>
<string name= "create_chat" > Create chat</string>
<string name= "create_new_chat" > Create new chat</string>
<string name= "create_profile" > Create profile</string>
<string name= "create_project" > Create project</string>
feat(marmot): put a # in front of every group's name, and retire (#admins)
A device's room list holds two unrelated kinds of room and nothing on a row said
which. A NIP-17 room is a conversation between the people in it. A Marmot room is
a *group* -- an id its key derives, a membership baked into an MLS tree, admins
who can act for it, a signature anyone holding the id can check -- and the two
behave differently enough that guessing is a mistake.
`"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half.
Only the admin room got it; a subgroup got no marker at all, so as soon as a group
had one child, half the Marmot rooms on the device were unmarked. It also sorted
nowhere near the group it belonged to, and a truncated row drops a trailing suffix
first -- so the marker was missing exactly where the list is crowded enough to
need it.
**The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than
where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every
member is welcomed with, so a `#` added at display time would be a name this
device alone could see. `#Ekklesia` marks both kinds of group room, and marks them
at the front.
**Three mints, because there are three ways a Marmot room comes into existence.**
`MarmotGroupCreation.create` is the funnel for two of them -- the admin room a
group opens after its ceremony, and a subgroup -- and normalising there means
neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s
convenient room, has a random id rather than a derived one, so it has no key state
to adopt and no admin set to bake in and does not pass through that funnel; it
applies the rule itself.
**Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice
from the same bare ceremony-room subject, by two callers that never see each
other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's
quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room
carries. Those two have to be the same string, or the subgroup is not called what
its parent certified -- and a certificate is a signature over the name, so a
verifier comparing them would see a real mismatch. `of` being idempotent is what
makes them agree by construction rather than by both sites being kept in step.
**The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup
is made, not the subgroup -- and prefixing it too produced two identically-named
rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits
beside `#Translators` (the group it stood up), which is the distinction the `#`
exists to draw. Its subject is trimmed, so the bare name and the two normalised
ones cannot differ by whitespace.
**The `#` is drawn beside the name field, not pushed into its state.** `name` in
`SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the
convention, because normalising on every keystroke moves the caret out from under
somebody halfway through a word. The coordinator still reads the name they are
about to get.
Four strings lose the old name -- "Create the #admins group" becomes "Create the
admin room", and the three about what "the #admins room" will sign with now say
"the admin room". Their keys are renamed with them, since the keys in this
catalogue are derived from the text. Around twenty comments, two screen previews
and seven test fixtures follow.
Docs: the ceremony note states the convention and what it replaces, and the
subgroups note's name-field section is rewritten -- it had been arguing from the
`"${parent.subject} (#admins)"` synthesis that no longer exists.
`docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured
debugging log, and rewriting it would falsify a record.
Three tests. `MarmotGroupNameTest` pins the rule, idempotence included.
`MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both
the room row this device draws and the group data every other member reads.
`SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed
event's tags back out of the signing session: the name the parent is asked to sign
is the name `MarmotGroupCreation` will give the room. That last one needed the
signable-parent fixture to seed host keys, since a ceremony's signer ids are
derived from them rather than stored.
**Rooms that already exist keep their names.** The name lives in the epoch-0 group
context, so renaming one is an MLS commit every member has to process -- a
different change from a naming convention, and not made here.
403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case
strings and 0 dp literals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:41:30 +02:00
<string name= "create_the_admin_room" > Create the admin room</string>
feat: sign a group's key state before its room exists, and put FROST on NIP-17
A room's `GroupKeyState` was the new #admins room's first application message:
the coordinator created the room, added the members, and only then asked the
group to agree what it signs with. The order is now reversed. The group agrees
it while it is still just a ceremony and a NIP-17 chat, and the room is created
already knowing.
**Two things were wrong with the old order, and neither was cosmetic.** The
room's founding fact was settled after the founding, so a session that never
reached a quorum left a live room whose every member fell back to rederiving --
which works, but only at the one path the constant names, and says nothing about
which ceremony a device should take its share from. And the members who had to
sign it were exactly the ones the room had just been created to hold: a member
whose key package could not be found was excluded from the room *and* from a
decision they held a share of, while `createAdminGroup` refuses to create the
room at all in that case. Agreeing first makes the state a precondition of the
room rather than an afterthought.
**Signing therefore has to work in a NIP-17 room, and `broadcast` is the only
place that knows.** In a Marmot room a signing message stays an ordinary inner
event, encrypted to the group and addressed to nobody, because who is in the
group is the MLS tree's business. In a NIP-17 room it goes out as one sealed
gift wrap per member and has to name them all, or the members it left out never
hear. Neither shape lets a recipient list decide anything -- the signer set
comes from the ceremony's host keys either way -- so tagging somebody does not
put them in it and failing to tag somebody only stops them hearing. Everything
above `broadcast` is the same protocol; `NostrDao` dispatches the 3032x kinds
off the gift-wrap path beside the DKG's, and the outbound path needed no change
because `sealGiftWrapPayload` already seals to the room's participants and
already refuses MLS rooms.
**`signingPath` gains the one case that cannot be self-checked.** Every other
candidate is right exactly when walking it reaches the room, which makes the
resolution self-checking rather than trusting. A NIP-17 room's id is an
aggregation of its members' keys, so no path reaches it and nothing can be
checked that way. What the group signs as there is the room it is about to make:
the ceremony's key at the app's admin path. That is admitted only when the
ceremony is *this room's own* -- `key.chatRoomId == chatRoomId`, read from this
device's database -- and the path is the constant rather than anything off the
wire, so a proposer still chooses nothing. Naming some other ceremony this
device holds a share for gets no path at all, and `completedKey` will not even
find a key for a NIP-17 room that did not host one, so such a room cannot open a
session; both are tested.
**A state's subject is now its own `d` tag, not the room it arrived in.** Those
used to be required to agree, and a mismatch was dropped -- the right rule while
a state was made in the room it described, and the wrong one now that the two
differ by design. Nothing is given up. The check that drop was standing in for
is still made and made against the *named* room: `GroupKeyState.verifies` has to
rederive it, and `isSignedByGroup` has to find a signature by the key that
rederivation reaches. A state can therefore only ever be about a room it
derives, whatever room it turned up in, so nobody can point one room at another
room's key by putting it through the wrong door. The arrival room survives only
as the fallback for a state carrying no `d` tag at all.
**`record` holds what it cannot file; `adopt` files it when there is a room.**
`GroupKeyState.chatRoomId` is a foreign key, so a state signed before its room
exists has nothing to hang on -- which is now the normal case rather than an
error. `record` says so and keeps the signed event; `adopt` reads it back off
`GroupSignedEvent` and files it the moment a room appears. Both ways into a room
end there: the member who creates it, in `createAdminGroup` and before the
members are added, since filing is local and doing it while the room is certain
to exist beats doing it after a step that can partly fail; and the member who
arrives on a Welcome, in `NostrDao`, off the same event they were already
holding because it was signed in the room they were already in. Nothing goes on
the wire in either case. A member who was not in the ceremony holds no such
event and gets nothing, which is right -- they hold no share either, so there is
nothing for them to pick the wrong one of.
**The screen watches the signed event, not a state row, and that is not
interchangeable.** There is no row until there is a room, so the only thing that
can say the agreement was reached is the event. `observeSignedGroupKeyState`
is a flow over `GroupSignedEvent` by kind for the same reason the button it
gates exists. Gating on the session's own items instead was rejected twice over:
`complete` writes `stage = COMPLETE` *before* `recordSignedEvents`, so a
collector woken by the session row can read before the event lands; and an item
can hold a signature that has not been verified yet -- `complete` is where each
one is checked against its id and author, and throws if it is not.
**The button is one control and two steps, in the order they have to happen.**
"Agree the group's signing key" until a quorum has signed, "Create the #admins
group" after. Offering both at once would be the old order still available, and
`createAdminGroup` refuses it in the view model as well, since the screen not
drawing something is not a guard. A failed session re-offers the propose button
and nothing else does, because a retry has to be a *new* session: the failed
one's nonce seeds have already been published against an aggregate, and reusing
one produces two partial signatures under a single secret nonce, which is how a
share is extracted. `propose` mints a fresh session id every time, so tapping it
is the safe retry by construction.
**One bug found in review, which the tests now pin.** `replayStoredMessages`
read only `marmotInnerEventDao`, so in a NIP-17 room a message arriving before
the proposal it belongs to -- routine on a fresh sync, where a relay hands over a
backlog in whatever order it likes -- was stored in the gift-wrap payloads and
never read back. It now reads whichever store the room's transport writes to,
which has to be the same reading `broadcast` makes. `a nonce arriving before the
proposal is replayed out of the gift wraps` fails against the old code.
**One wart, taken deliberately.** `GroupSignedEvent.chatRoomId` means the room a
signature was made in, which for every event but this one is also the room whose
key signed it. The key state is filed under the ceremony's room and authored by
the #admins room, so `GroupSignedEvent.verifies` cannot pass on that row --
check it with `GroupKeyStateEvent.isSignedByGroup`, which asks the question the
row cannot. Both columns are documented to say so. Re-filing the row under the
#admins room once it exists was the alternative and buys nothing: a key state is
not chroniclable, so no reader wants it there, and moving a row to keep one
helper honest is worse than saying where the helper stops.
`ChronicleManager` and `docs/member-chronicle.md` both argued for the
`isChroniclable` filter from "every room signs a `GroupKeyStateEvent` as its
first act", which is no longer true of any Marmot room. The filter stays and the
argument is restated: what it stops is a member replaying any group-signed
statement *about* the record as though it were work, and `applyPage` refuses the
same kinds coming the other way. The two are a pair and neither is safe to drop
on the strength of the other. `ChronicleAssemblyJvmTest` now puts its key state
on file by hand, which makes that test sharper rather than hypothetical.
`SignedGroupKeyStateTest`'s harness flattens the two transports into one
`Queued` shape and each device declares whether its room has MLS state, so every
existing test keeps testing the Marmot path and the seven new ones read the
same. `GroupKeyStateTest`'s "a state naming another group's key is dropped"
splits in two: one holding the room fixed and varying the key, which is still a
drop, and one varying both, which is another group's true statement and is now
attributed to that group's room rather than refused.
649 jvm tests and 373 common tests pass; `m3Audit` meets every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:15:58 +02:00
<string name= "agree_the_groups_signing_key" > Agree the group's signing key</string>
feat(marmot): put a # in front of every group's name, and retire (#admins)
A device's room list holds two unrelated kinds of room and nothing on a row said
which. A NIP-17 room is a conversation between the people in it. A Marmot room is
a *group* -- an id its key derives, a membership baked into an MLS tree, admins
who can act for it, a signature anyone holding the id can check -- and the two
behave differently enough that guessing is a mistake.
`"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half.
Only the admin room got it; a subgroup got no marker at all, so as soon as a group
had one child, half the Marmot rooms on the device were unmarked. It also sorted
nowhere near the group it belonged to, and a truncated row drops a trailing suffix
first -- so the marker was missing exactly where the list is crowded enough to
need it.
**The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than
where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every
member is welcomed with, so a `#` added at display time would be a name this
device alone could see. `#Ekklesia` marks both kinds of group room, and marks them
at the front.
**Three mints, because there are three ways a Marmot room comes into existence.**
`MarmotGroupCreation.create` is the funnel for two of them -- the admin room a
group opens after its ceremony, and a subgroup -- and normalising there means
neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s
convenient room, has a random id rather than a derived one, so it has no key state
to adopt and no admin set to bake in and does not pass through that funnel; it
applies the rule itself.
**Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice
from the same bare ceremony-room subject, by two callers that never see each
other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's
quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room
carries. Those two have to be the same string, or the subgroup is not called what
its parent certified -- and a certificate is a signature over the name, so a
verifier comparing them would see a real mismatch. `of` being idempotent is what
makes them agree by construction rather than by both sites being kept in step.
**The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup
is made, not the subgroup -- and prefixing it too produced two identically-named
rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits
beside `#Translators` (the group it stood up), which is the distinction the `#`
exists to draw. Its subject is trimmed, so the bare name and the two normalised
ones cannot differ by whitespace.
**The `#` is drawn beside the name field, not pushed into its state.** `name` in
`SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the
convention, because normalising on every keystroke moves the caret out from under
somebody halfway through a word. The coordinator still reads the name they are
about to get.
Four strings lose the old name -- "Create the #admins group" becomes "Create the
admin room", and the three about what "the #admins room" will sign with now say
"the admin room". Their keys are renamed with them, since the keys in this
catalogue are derived from the text. Around twenty comments, two screen previews
and seven test fixtures follow.
Docs: the ceremony note states the convention and what it replaces, and the
subgroups note's name-field section is rewritten -- it had been arguing from the
`"${parent.subject} (#admins)"` synthesis that no longer exists.
`docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured
debugging log, and rewriting it would falsify a record.
Three tests. `MarmotGroupNameTest` pins the rule, idempotence included.
`MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both
the room row this device draws and the group data every other member reads.
`SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed
event's tags back out of the signing session: the name the parent is asked to sign
is the name `MarmotGroupCreation` will give the room. That last one needed the
signable-parent fixture to seed host keys, since a ceremony's signer ids are
derived from them rather than stored.
**Rooms that already exist keep their names.** The name lives in the epoch-0 group
context, so renaming one is an MLS commit every member has to process -- a
different change from a naming convention, and not made here.
403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case
strings and 0 dp literals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:41:30 +02:00
<string name= "the_group_is_agreeing_what_the_admin_room" > The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat.</string>
<string name= "the_group_has_agreed_what_the_admin_room" > The group has agreed what the admin room will sign with.</string>
<string name= "the_group_could_not_agree_what_the_admin" > The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry.</string>
feat: sign a group's key state before its room exists, and put FROST on NIP-17
A room's `GroupKeyState` was the new #admins room's first application message:
the coordinator created the room, added the members, and only then asked the
group to agree what it signs with. The order is now reversed. The group agrees
it while it is still just a ceremony and a NIP-17 chat, and the room is created
already knowing.
**Two things were wrong with the old order, and neither was cosmetic.** The
room's founding fact was settled after the founding, so a session that never
reached a quorum left a live room whose every member fell back to rederiving --
which works, but only at the one path the constant names, and says nothing about
which ceremony a device should take its share from. And the members who had to
sign it were exactly the ones the room had just been created to hold: a member
whose key package could not be found was excluded from the room *and* from a
decision they held a share of, while `createAdminGroup` refuses to create the
room at all in that case. Agreeing first makes the state a precondition of the
room rather than an afterthought.
**Signing therefore has to work in a NIP-17 room, and `broadcast` is the only
place that knows.** In a Marmot room a signing message stays an ordinary inner
event, encrypted to the group and addressed to nobody, because who is in the
group is the MLS tree's business. In a NIP-17 room it goes out as one sealed
gift wrap per member and has to name them all, or the members it left out never
hear. Neither shape lets a recipient list decide anything -- the signer set
comes from the ceremony's host keys either way -- so tagging somebody does not
put them in it and failing to tag somebody only stops them hearing. Everything
above `broadcast` is the same protocol; `NostrDao` dispatches the 3032x kinds
off the gift-wrap path beside the DKG's, and the outbound path needed no change
because `sealGiftWrapPayload` already seals to the room's participants and
already refuses MLS rooms.
**`signingPath` gains the one case that cannot be self-checked.** Every other
candidate is right exactly when walking it reaches the room, which makes the
resolution self-checking rather than trusting. A NIP-17 room's id is an
aggregation of its members' keys, so no path reaches it and nothing can be
checked that way. What the group signs as there is the room it is about to make:
the ceremony's key at the app's admin path. That is admitted only when the
ceremony is *this room's own* -- `key.chatRoomId == chatRoomId`, read from this
device's database -- and the path is the constant rather than anything off the
wire, so a proposer still chooses nothing. Naming some other ceremony this
device holds a share for gets no path at all, and `completedKey` will not even
find a key for a NIP-17 room that did not host one, so such a room cannot open a
session; both are tested.
**A state's subject is now its own `d` tag, not the room it arrived in.** Those
used to be required to agree, and a mismatch was dropped -- the right rule while
a state was made in the room it described, and the wrong one now that the two
differ by design. Nothing is given up. The check that drop was standing in for
is still made and made against the *named* room: `GroupKeyState.verifies` has to
rederive it, and `isSignedByGroup` has to find a signature by the key that
rederivation reaches. A state can therefore only ever be about a room it
derives, whatever room it turned up in, so nobody can point one room at another
room's key by putting it through the wrong door. The arrival room survives only
as the fallback for a state carrying no `d` tag at all.
**`record` holds what it cannot file; `adopt` files it when there is a room.**
`GroupKeyState.chatRoomId` is a foreign key, so a state signed before its room
exists has nothing to hang on -- which is now the normal case rather than an
error. `record` says so and keeps the signed event; `adopt` reads it back off
`GroupSignedEvent` and files it the moment a room appears. Both ways into a room
end there: the member who creates it, in `createAdminGroup` and before the
members are added, since filing is local and doing it while the room is certain
to exist beats doing it after a step that can partly fail; and the member who
arrives on a Welcome, in `NostrDao`, off the same event they were already
holding because it was signed in the room they were already in. Nothing goes on
the wire in either case. A member who was not in the ceremony holds no such
event and gets nothing, which is right -- they hold no share either, so there is
nothing for them to pick the wrong one of.
**The screen watches the signed event, not a state row, and that is not
interchangeable.** There is no row until there is a room, so the only thing that
can say the agreement was reached is the event. `observeSignedGroupKeyState`
is a flow over `GroupSignedEvent` by kind for the same reason the button it
gates exists. Gating on the session's own items instead was rejected twice over:
`complete` writes `stage = COMPLETE` *before* `recordSignedEvents`, so a
collector woken by the session row can read before the event lands; and an item
can hold a signature that has not been verified yet -- `complete` is where each
one is checked against its id and author, and throws if it is not.
**The button is one control and two steps, in the order they have to happen.**
"Agree the group's signing key" until a quorum has signed, "Create the #admins
group" after. Offering both at once would be the old order still available, and
`createAdminGroup` refuses it in the view model as well, since the screen not
drawing something is not a guard. A failed session re-offers the propose button
and nothing else does, because a retry has to be a *new* session: the failed
one's nonce seeds have already been published against an aggregate, and reusing
one produces two partial signatures under a single secret nonce, which is how a
share is extracted. `propose` mints a fresh session id every time, so tapping it
is the safe retry by construction.
**One bug found in review, which the tests now pin.** `replayStoredMessages`
read only `marmotInnerEventDao`, so in a NIP-17 room a message arriving before
the proposal it belongs to -- routine on a fresh sync, where a relay hands over a
backlog in whatever order it likes -- was stored in the gift-wrap payloads and
never read back. It now reads whichever store the room's transport writes to,
which has to be the same reading `broadcast` makes. `a nonce arriving before the
proposal is replayed out of the gift wraps` fails against the old code.
**One wart, taken deliberately.** `GroupSignedEvent.chatRoomId` means the room a
signature was made in, which for every event but this one is also the room whose
key signed it. The key state is filed under the ceremony's room and authored by
the #admins room, so `GroupSignedEvent.verifies` cannot pass on that row --
check it with `GroupKeyStateEvent.isSignedByGroup`, which asks the question the
row cannot. Both columns are documented to say so. Re-filing the row under the
#admins room once it exists was the alternative and buys nothing: a key state is
not chroniclable, so no reader wants it there, and moving a row to keep one
helper honest is worse than saying where the helper stops.
`ChronicleManager` and `docs/member-chronicle.md` both argued for the
`isChroniclable` filter from "every room signs a `GroupKeyStateEvent` as its
first act", which is no longer true of any Marmot room. The filter stays and the
argument is restated: what it stops is a member replaying any group-signed
statement *about* the record as though it were work, and `applyPage` refuses the
same kinds coming the other way. The two are a pair and neither is safe to drop
on the strength of the other. `ChronicleAssemblyJvmTest` now puts its key state
on file by hand, which makes that test sharper rather than hypothetical.
`SignedGroupKeyStateTest`'s harness flattens the two transports into one
`Queued` shape and each device declares whether its room has MLS state, so every
existing test keeps testing the Marmot path and the seven new ones read the
same. `GroupKeyStateTest`'s "a state naming another group's key is dropped"
splits in two: one holding the room fixed and varying the key, which is still a
drop, and one varying both, which is another group's true statement and is now
attributed to that group's room rather than refused.
649 jvm tests and 373 common tests pass; `m3Audit` meets every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:15:58 +02:00
<string name= "before_the_room_exists_the_group_signs" > Before the room exists, the group signs a statement of which key it will sign with. The room is then created already knowing it.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "creating_new_chat" > Creating new chat.</string>
<string name= "currently_no_contacts_please_search_and_chat" > Currently no contacts. Please search and chat with a few people.</string>
<string name= "currently_no_messages_have_been_shared" > Currently no messages have been shared.\nBreak the ice.</string>
<string name= "delete_group" > Delete group</string>
<string name= "details" > Details</string>
<string name= "dialect_name" > Dialect name</string>
<string name= "dialects" > Dialects</string>
<string name= "direct_message_detail" > Direct message detail</string>
<string name= "direct_message_functionality_will_be_here" > Direct message functionality will be here.</string>
<string name= "direct_message_via_npub" > Direct message via npub</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "display_recovery_phrase" > Display recovery phrase</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "don_t_sign" > Don't sign</string>
<string name= "download" > Download</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "download_and_securely_store_everything" > Download and securely store everything needed to recover this profile and the coins it holds.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "edit_profile" > Edit profile</string>
<string name= "eg_chapter_1_the_beginning" > eg. Chapter 1 — The Beginning</string>
<string name= "eg_first_edition" > eg. First Edition</string>
<string name= "eg_https_harper_com_2_kill_bird" > eg. https://harper.com/2-kill-Bird</string>
<string name= "eg_lesotho" > eg. Lesotho</string>
<string name= "eg_sesotho" > eg. Sesotho</string>
<string name= "eg_st" > eg. st</string>
<string name= "eg_to_kill_a_mocking_bird" > eg. To Kill a Mocking Bird</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "emergency_kit" > Emergency kit</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "end_this" > end this</string>
<string name= "ended" > ENDED</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "encrypt_and_back_your_recovery_information" > Encrypt and back your recovery information up to your Google Drive or iCloud.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "enter_the_name_you_want_to_use_for_your" > Enter the name you want to use for your group</string>
<string name= "enter_the_name_you_want_to_use_for_your_2" > Enter the name you want to use for your profile</string>
<string name= "enter_the_translation_for_this_chunk" > Enter the translation for this chunk</string>
<string name= "events_are_indexed_so_that_we_can_deliver_a" > Events are indexed so that we can deliver a premium local first experience.</string>
<string name= "everything_else" > Everything else</string>
<string name= "everything_is_cryptographical_sound_just" > Everything is cryptographical sound. Just announcing your profile to the world.</string>
<string name= "everything_is_cryptographical_sound_just_2" > Everything is cryptographical sound. Just indexing your profile on the device.</string>
<string name= "everything_is_cryptographical_sound_just_3" > Everything is cryptographical sound. Just need to queue your profile and announce it to the world.</string>
<string name= "expired" > Expired</string>
<string name= "failed" > ✗ Failed</string>
<string name= "follow" > follow</string>
<string name= "follow_back" > follow back</string>
<string name= "functionality_coming_soon" > functionality coming soon.</string>
<string name= "has_not_taken_part_yet" > Has not taken part yet</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "hide" > Hide</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "how_many_admins_have_to_approve_a_change" > How many admins have to approve a change?</string>
<string name= "how_many_members_will_it_take_to_sign" > How many members will it take to sign?</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "i_have_saved_my_recovery_phrase_somewhere" > I have saved my recovery phrase somewhere safe.</string>
<string name= "i_understand_that_if_i_lose_this_phone_and" > I understand that if I lose this phone and my recovery phrase, I lose this profile and the funds in its wallet.</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out
Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.
**Sixteen copies of the same dead end.** The tree held sixteen instances of
Column(horizontalAlignment = CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text("Something went wrong")
}
and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.
`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.
`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.
**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.
`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.
It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.
**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.
**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)
**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name= "if_you_are_new_to_torch_or_just_want_to" > If you are new to Mantra or just want to create a fresh profile</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "initial_version_label" > Initial version label</string>
<string name= "input_npub_or_nip05" > Input npub... or nip05</string>
<string name= "introduce_yourself" > Introduce yourself</string>
<string name= "invite" > Invite</string>
<string name= "invite_a_friend" > Invite a friend</string>
<string name= "invite_new_member" > Invite new member</string>
<string name= "it_is_cryptographical_secure_and" > It is cryptographical secure, and decentralized, putting you in total control of your digital profile</string>
<string name= "just_you_for_now" > Just you for now</string>
<string name= "keep_the_feed_alive" > Keep the feed alive.</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "keep_this_phrase_safe_do_not_share_it" > Keep this phrase safe.\nDo not share it.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "key" > Key</string>
<string name= "key_package_management" > Key package management</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "key_recovery" > Key recovery</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "language" > Language</string>
<string name= "learn_more" > Learn more</string>
<string name= "leave_group" > Leave group</string>
<string name= "library" > Library</string>
<string name= "lightning_invoice" > Lightning invoice</string>
<string name= "live" > LIVE</string>
<string name= "live_stream" > Live stream</string>
<string name= "loading" > Loading</string>
<string name= "loading_article" > Loading article...</string>
<string name= "loading_author_information" > Loading author information</string>
<string name= "loading_author_information_2" > Loading author information...</string>
<string name= "loading_information" > loading information...</string>
<string name= "loading_note" > Loading note...</string>
<string name= "loading_stream" > Loading stream...</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "loading_preferences" > Loading preferences…</string>
<string name= "lose_this_phone_before_you_do_and_the" > Lose this phone before you do, and the profile goes with it.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "lock_prompt_coming_soon" > Lock prompt coming soon.</string>
<string name= "malformed_note" > Malformed note</string>
<string name= "mantra" > Mantra</string>
<string name= "members" > Members</string>
<string name= "members_2" > members</string>
<string name= "name" > Name</string>
<string name= "name_eg_alan_turing" > Name (eg. Alan Turing)</string>
<string name= "name_eg_group_discussions" > Name (eg. Group Discussions)</string>
<string name= "name_of_artifact" > Name of artifact</string>
<string name= "network_relays" > Network relays</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "no_wallet_is_open_on_this_device_so_there" > No wallet is open on this device, so there is no phrase to show.</string>
<string name= "not_backed_up_yet" > Not backed up yet</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "new_chat" > New chat</string>
<string name= "next" > Next</string>
<string name= "no_artifacts_exists_in_this_groups_library" > No artifacts exists in this groups library.</string>
<string name= "no_chapters" > No chapters.</string>
<string name= "no_chapters_yet" > No chapters yet.</string>
<string name= "no_chat_message_relays_were_found_for_this" > No chat message relays were found for this user.</string>
<string name= "no_chunks" > No chunks.</string>
<string name= "no_dialects_have_been_defined_in_this_group" > No dialects have been defined in this group yet. Add one from the group's detail screen first.</string>
<string name= "no_dialects_have_been_defined_in_this_group_2" > No dialects have been defined in this group.</string>
<string name= "no_messages_go_to_a_profile_and_send_them_a" > No messages. Go to a profile and send them a message.</string>
<string name= "no_one_selected_yet" > No one selected yet</string>
<string name= "no_one_to_add_yet" > No one to add yet.</string>
<string name= "no_projects_exists_in_this_group" > No projects exists in this group.</string>
<string name= "no_translations_yet" > No translations yet.</string>
<string name= "no_versions" > No versions.</string>
<string name= "not_now" > Not now</string>
<string name= "nothing_was_created_and_no_key_exists_it_is" > Nothing was created and no key exists. It is safe to run it again.</string>
<string name= "open_chat" > Open chat</string>
<string name= "original" > Original</string>
<string name= "original_text" > Original text</string>
<string name= "original_text_markdown" > Original text (markdown)</string>
<string name= "paid" > ✓ Paid</string>
<string name= "paste_the_chapter_s_markdown_blank_lines" > Paste the chapter's markdown. Blank lines separate paragraphs into chunks.</string>
<string name= "pay" > Pay</string>
<string name= "pay_now" > Pay now</string>
<string name= "post" > Post</string>
<string name= "post_functionality_coming_soon" > post functionality coming soon.</string>
<string name= "private_to_you" > Private to you</string>
feat: give the app a navigation component, and stop the app bar duplicating it
Phase 6, step 3. The app had no navigation component of any kind: 43 screens
reached by pushing a route, and one home screen whose top app bar carried the
only two peer surfaces -- a profile avatar in the leading slot, a search icon in
the trailing one.
**This is an information-architecture change and was taken as one.** With a
single top-level destination, a navigation bar would have held one item and been
strictly worse than the app bar it replaced -- M3's caution is to swap only
functionally equivalent components. Promoting search and profile to peer
destinations is what makes a navigation component mean anything here, and it was
put to the product owner rather than inferred. Answered: promote them.
The consequence is in `HomeScreen`: the app bar now carries a title and nothing
else. Two routes to one destination is the thing the caution is about, and the
navigation component is now the one route, at every breakpoint.
**Which component, at which breakpoint**, straight from the layout foundation:
| compact | navigation bar |
| medium, expanded | collapsed rail |
| large, extra-large | expanded rail |
`NavigationSuiteScaffoldDefaults.navigationSuiteType` is not used, and the
difference is the last row -- it stops at the collapsed rail, because it
classifies with the three-value window size class rather than the five
breakpoints the May 2026 revision published. Deriving from `Breakpoint` reaches
the row the library's default cannot, and keeps one source of truth for window
width in the app.
`NavigationSuiteType.None` on everything else. A navigation bar belongs on the
destinations it switches between; on a chat room, a signing screen or an
onboarding step -- pushed to and left by coming back -- it is a permanent
invitation to lose your place.
**Two things the wiring needed.**
`ActiveProfileRoute` is addressed by metadata event id, not by public key, and
only the home screen ever had one. The nav host now observes it for as long as a
key is signed in, and the profile item is *disabled* until it arrives rather than
absent -- an item that appears late moves the two beside it, and a bar whose
items move under a thumb is worse than one briefly unavailable.
The item click pops to `HomeRoute`, not to the graph's start destination. The
android docs give the second shape and it would be wrong here: this graph starts
at `LoadingRoute`, and onboarding clears the stack with `popUpTo(0)` on its way
to home, so by the time these items exist the start destination is not on the
stack at all -- popping to it would leave the loading screen underneath as the
thing back returns to.
**Tests, and one that could not be written.** The breakpoint-to-component table
is a pure function so all five rows are asserted; the two rail rows differ only
in whether labels are drawn, and nobody opens a 1200dp window on purpose. Four
more compose the component around a real nav graph, because `TopLevelDestination.of`
matches by `hasRoute` -- reflection over the serialized route -- and a renamed
route would fail by never showing the component at all.
Navigation in those is driven through the controller rather than by tapping an
item. That is a harness limitation, established rather than assumed: a click
handler that navigates trips navigation-compose's own main-thread assertion under
`runDesktopComposeUiTest`, reproducible in twenty lines containing no app code --
a `NavHost`, two routes and a `TextButton`. What an item's `onClick` builds is
asserted where it is a pure function instead.
Also `material3-adaptive-navigation-suite`, versioned with material3 rather than
with the adaptive library: it is published by the material3 group, and its
1.10.0-alpha05 is what names adaptive 1.2.0 in the first place.
Most of the `MantraNavHost` diff is indentation -- the `NavHost` call gained an
enclosing composable. `git diff -w` shows the 38 lines that are not.
626 jvm tests green; android compiles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:53:27 +02:00
<string name= "messages" > Messages</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "profile" > Profile</string>
feat: put the chat list beside the conversation, from the expanded breakpoint up
Phase 6, step 4, chat first as the plan asks. On a window 840dp or wider the home
screen is now the room list at a fixed width and the selected conversation
filling the rest; on anything narrower it is exactly what it was.
**Below expanded is not caution, it is the spec.** The breakpoints page says not
to put two dense panes in a medium window, and `calculatePaneScaffoldDirective`
in `material3-adaptive` says the same thing in code -- `maxHorizontalPartitions
= 1` for compact and medium alike. A chat transcript is precisely the dense
content that rule is about.
It is also what this app can support. `ChatRoomMessagingRoute` is navigated to
from **eleven** places -- a DKG ritual finishing, room-type selection, the npub
dialog, a profile -- so the conversation has to remain a pushed destination
whatever the window is doing. The list pane is a second way to reach it on a wide
window, not a replacement for the first.
**Why not `ListDetailPaneScaffold`.** The dependency is available and resolves
for every target; the scaffold was not used, and the reason is the paragraph
above. It earns its API surface -- a navigator, a destination history, an
`AnimatedPane` per pane, three experimental opt-ins -- by owning the single-pane
case as well: showing the detail *instead of* the list on a phone and animating
between them. This app cannot hand it that, so it would sit permanently in its
two-pane state and amount to a `Row` with more words and a history nothing reads.
What it does have that is worth keeping is its numbers, and `Panes.kt` takes
them: 360dp of list at expanded, 412dp from large upward, 24dp between. A
hand-built pair measures the same as the scaffold would.
**Three smaller decisions.**
The floating action button moves into the list pane when there are two. The
`Scaffold`'s slot is the bottom-right of the *window*, which with two panes is on
top of the transcript's send button; M3 puts a list-detail layout's primary
action in the list pane. It is one composable used from both branches so the two
cannot drift.
`readableContent()` comes off the pair. Capping two panes together to one
column's measure is the opposite of what a second pane is for -- each pane holds
its own content instead, and the conversation already did.
The detail pane says "Pick a conversation to read it here" rather than being an
unexplained empty half of a window, and the conversation is keyed on the room so
switching rebuilds its view models rather than feeding a new id to ones already
subscribed to another room's relays.
**Measured in real windows of the widths the phase names.** 400 and 700 are one
pane; 1000 splits with a 360dp list; 1400 splits with a 412dp list. The
repositories are the no-op ones with the two reads this screen makes delegated to
a fixed answer -- Kotlin's interface delegation makes that ten lines rather than
a reimplementation of two large interfaces. Five more unit tests pin the widths
against the directive's, including that the detail pane still clears a
40-character line in the narrowest window that allows two of them.
635 jvm tests green; android compiles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:01:16 +02:00
<string name= "pick_a_conversation_to_read_it_here" > Pick a conversation to read it here.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "profile_is_ready" > Profile is ready</string>
<string name= "profiles" > Profiles</string>
<string name= "projects" > Projects</string>
<string name= "proposals" > Proposals</string>
<string name= "propose" > Propose</string>
<string name= "propose_artifact" > Propose artifact</string>
<string name= "propose_chapter" > Propose chapter</string>
<string name= "propose_dialect" > Propose dialect</string>
<string name= "propose_translation" > Propose translation</string>
<string name= "publish_new_key_package" > Publish new key package</string>
<string name= "re_broadcast" > Re-broadcast</string>
<string name= "read_to_the_end_of_the_list_to_sign" > Read to the end of the list to sign.</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "recovery_phrase" > Recovery phrase</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "ready_to_sign" > Ready to sign</string>
<string name= "recents" > Recents</string>
<string name= "reindex_events" > Reindex events</string>
<string name= "reposted" > reposted</string>
<string name= "review" > Review</string>
<string name= "review_and_confirm" > Review and confirm</string>
<string name= "review_and_contribute" > Review and contribute</string>
<string name= "review_and_join" > Review and join</string>
<string name= "say_what_now" > Say what now?</string>
<string name= "search" > Search</string>
<string name= "search_for_people_and_chat_with_them_first" > Search for people and chat with them first — everyone you know locally shows up here.</string>
<string name= "search_hashtags" > Search hashtags</string>
<string name= "search_member_functionality" > Search member functionality</string>
<string name= "search_message_functionality_will_be_here" > Search message functionality will be here.</string>
<string name= "see_how_deep_the_rabbit_hole_goes" > See how deep the rabbit hole goes</string>
<string name= "select_a_wallet" > Select a wallet</string>
<string name= "send_message" > Send message</string>
<string name= "share_profile" > Share profile</string>
<string name= "shared_key" > Shared key</string>
feat: put a room's signing key first on its detail screen, and the signed event behind it
A group's `GroupKeyState` had no surface anywhere in the app. It decides which
share a member signs with and which identity a reader will see on everything the
group signs, and the only way to learn either was to read the logs. The group
detail screen now opens with it, and tapping it shows the event a quorum
actually put its signature to, with a button to take that event somewhere it can
be checked.
**First on the screen, above the description.** Which key a room signs as is the
fact the rest of the room's signed work stands on -- a dialect, an artifact and a
chapter are all worth exactly what the identity behind them is worth -- so it
goes before the library and the dialects rather than into the settings-ish tail
of the screen with reindexing and leaving. It is absent rather than empty on a
room the group has said nothing about: there is no half state to report, since a
room either has one a quorum signed or has none, and the shared key entry further
down is already where somebody goes to make one.
**The row's subtitle is the identity, not the threshold key.** Those are
different values -- the group's root ChillDKG key, and that key walked to the
room's path -- and only the second one appears on anything. It is what a reader
checks a signature against and it is the room's own id, so it is the value a
member is most likely to want to compare against something. The whole of it,
along with the root key it came from, is one tap away in the sheet.
**The state and the event are read separately, and neither is derived from the
other.** A `GroupSignedEvent` carries every field the `GroupKeyState` row does,
so one read would have done -- but the two mean different things when they are
missing. The row is this device's reading, which is what the app resolves a
signing request against; the event is the group's statement, with the signature
on it, which is the only part that can be checked. A device holding the reading
and not the statement should not be shown fields as though they were signed, and
the sheet says so instead. It never happens the other way round: a state is only
ever written from an event that passed both checks.
**`signedEventFor` looks wherever the event is filed, which is not this room.**
Since the previous commit a group agrees its key state before the room exists, so
the event lives under the NIP-17 room its ceremony ran in and is authored by the
Marmot room it is about. Finding it by room would find nothing. It is found by
its `d` tag instead, through the same `stateFrom` that lets one be believed at
all, so nothing is shown that this device would not have acted on. `stateAmong`
and the new `signedEventFor` are now one walk returning both halves, because an
event that produces no state must not be shown as though the group had settled
anything.
**The sheet shows and copies the canonical compact event JSON.** Pretty-printing
it would read better in the block and was rejected: the point of copying it is to
hand somebody something they can verify, and the moment the display and the copy
diverge the button stops being "copy what you are looking at". What is shown is
`Event.toJson()`, byte for byte, which is what a nostr tool expects to be given.
**The hex is grouped in eights, which the render caught and reading did not.**
Captured off a real desktop composition at 360dp, the JSON wrapped fine -- it has
quotes and commas to break on -- and every key ran off the side of the sheet with
its last characters unreadable. A 64-character key has no space in it, so Compose
lays the whole run on one line and lets it overflow. The spaces are the break
opportunities. They are also how a value meant to be compared character by
character against another member's screen should have been shown in the first
place, for the same reason a fingerprint or an account number is grouped. Nothing
is copied from those fields, so shaping them for reading costs a paste nothing.
**The value colour is stated rather than inherited.** The labels are deliberately
quieter at `onSurfaceVariant` and the values are what a member came for, so they
name `onSurface` instead of taking whatever `LocalContentColor` happens to be.
The JSON block sits on `surfaceVariant`/`onSurfaceVariant`, which
`ColorSchemeContrastTest` already measures in all six schemes.
**The sheet's body is a composable of its own, and that is what makes it
testable.** A `ModalBottomSheet` is a popup in its own window, which a layout
test cannot reach into, so `GroupKeyStateSheetContent` is separated from the
sheet that contains it. `GroupKeyStateSheetLayoutJvmTest` then renders it at
phone width and asserts the *height* of the JSON: a parent that narrow caps the
text's layout width whether it wraps or clips, so width would pass either way.
The threshold is calibrated against the real measurement rather than guessed --
it renders 224dp wrapped, against roughly 16dp for a single clipped line, so 60dp
separates them with room to spare. A second case renders the sheet for a device
holding no signed event, since that branch returns early and would otherwise
never be laid out.
The screen's `@ConformancePreviews` gains a key state, so the row renders under
all five conditions rather than only in a group that has held a ceremony.
651 jvm tests and 373 common tests pass; `m3Audit` meets every budget, with the
string count unchanged at 39 -- the eleven new pieces of UI text are in the
catalogue in sentence case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:52:36 +02:00
<string name= "signing_key" > Signing key</string>
<string name= "the_key_this_room_signs_as" > The key this room signs as, and the ceremony it came from.</string>
<string name= "what_the_group_signed" > What the group signed</string>
<string name= "this_room_signs_as" > This room signs as</string>
<string name= "derivation_path" > Derivation path</string>
<string name= "key_ceremony" > Key ceremony</string>
<string name= "agreed_on" > Agreed on</string>
<string name= "the_signed_event" > The signed event</string>
<string name= "copy_the_signed_event" > Copy the signed event</string>
<string name= "copied_the_signed_event" > Copied the signed event</string>
<string name= "this_device_does_not_hold_the_event" > This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "sign" > Sign</string>
<string name= "sign_in" > Sign in</string>
<string name= "sign_out" > Sign out</string>
<string name= "sign_with_the_group_s_key" > Sign with the group's key</string>
<string name= "signed_their_part" > Signed their part</string>
<string name= "skip_for_now" > Skip for now</string>
<string name= "something_went_wrong" > Something went wrong</string>
<string name= "something_went_wrong_and_we_were_unable_to" > Something went wrong and we were unable to sign in to your provided profile. Please try again later.</string>
<string name= "something_went_wrong_and_we_were_unable_to_2" > Something went wrong and we were unable to sign you up. Please try again later.</string>
<string name= "something_went_wrong_and_we_were_unable_to_3" > Something went wrong and we were unable to write a new note. Please try again later.</string>
<string name= "source_dialect" > Source dialect</string>
<string name= "start" > Start</string>
<string name= "start_a_group_chat" > Start a group chat</string>
<string name= "start_chat" > Start chat</string>
<string name= "start_chat_via_npub_or_nip05" > Start chat via npub or nip05</string>
<string name= "start_key_ceremony" > Start key ceremony</string>
<string name= "startup_error" > Startup error</string>
<string name= "taking_part_with_these_members" > Taking part with these members</string>
<string name= "tap_to_load" > Tap to load</string>
<string name= "tell_friends_to_join_you_so_your_feed_stays" > Tell friends to join you so your feed stays lively and fresh.</string>
<string name= "the_above_will_be_your_new_note" > The above will be your new note.</string>
<string name= "the_above_will_be_your_profile" > The above will be your profile.</string>
<string name= "the_ceremony_was_abandoned" > The ceremony was abandoned.</string>
<string name= "the_group_can_hold_one_key_together_split_so" > The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum.</string>
<string name= "the_group_has_a_shared_key" > The group has a shared key.</string>
feat(groups): the queue of a list the group curates, read off the relays it named
`930d37c8` gave a group the schema: the definition of a list, signed by the room's
own key, for strangers to answer. This is the answers. Under every schema card on the
group's screen there is now a "View suggestions" button, and it opens the list's
*queue* -- every kind 31888 anyone has published in reply to the schema's coordinate,
newest first, whoever signed it, with a mark on each one the group has already taken
up into the list. It is the second half of the curated-list NIP in this app, the
read side of it: suggestions (31888) and canonical entries (31890) are now read and
checked here. They are still not written; that is the third half.
The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that
site's one list: one row per suggestion event rather than one per film, so that a
reader can see what is waiting before anything is added and whether the curator has
taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the
reference for everything the screen decides -- one row per event, the newest
version per coordinate, "Curated" where a canonical entry shares the `d`, and a
timer standing in for an answer that never comes.
**The button is behind no gate, and it is per card.** Every other button in the
identity block is hidden from a member holding no share of the key, because each of
them proposes a signature by the group. The queue is the one thing in the block the
group did not write. A schema exists to be answered by strangers, and reading the
answers takes no share of anything, so the button is there for whoever is looking
-- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a
schema and finds it. It sits under its own card rather than once for the section,
in the same `item {}` as the card, because each list has its own queue and a button
between two cards would say nothing about which one it opened.
**The protocol is ported rule for rule, and the fixtures are the NIP's own.**
`nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`,
`verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind
is the one the schema names; every required field is present; no field without
`repeat` appears twice; every value matches its field's type and config; every
`require-any` group is met; the `a` root names this schema and no other; and the
author is somebody the visibility admits. A canonical entry gets two rules on top --
its author is the schema's author, because curation is the one power a schema does
not delegate, and a source pointer, when there is one, is a well-formed suggestion
coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest`
takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's
example, and the curator's sign-off on it, and checks both against the bitcoin.mov
schema from the same document; then the doc's own minimum suggestion, which has an
IMDb link and no watch link and satisfies `require-any` that way; then every
rejection the doc lists and says why it matters -- an unknown type, a year outside
1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a
`d` with a space in it -- plus the reply rules, the repeat rule, the closed-list
rule and the two canonical rules.
**Rejected, not repaired, and read by field.** The NIP says a client MUST verify an
entry against its schema and MUST reject one that does not satisfy it rather than
showing it with the bad parts blanked out; relays carry malformed, partial and
hostile events from other applications. So `suggestion` and `canonical` return the
entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the
reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema.
problems` is an enum rather than a sentence -- so that a form later can show them
inline. What comes back is a `CuratedEntry` keyed by the schema's *field names*
rather than by tag, because a field is what a form and a screen know an entry by and
the tag is only where it was kept: the two `r` tags of the example land on
`watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body
on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no
field of the NIP's schema and are not in the reading -- "tags the schema does not
define MAY be present and MUST be ignored".
**A pattern this platform cannot compile counts as not matched.** The reference
builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on
a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring
an uncompilable pattern would accept entries the site refuses; refusing them is the
conservative reading, and it is documented at the check.
**A coordinate splits on the first two colons only.** A `d` may itself contain
colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses --
so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`,
and the test pins the identifier surviving its own colon and the pubkey being
lowercased on the way through.
**The queue is a reading over stored rows, with three things to close and one to
add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the
coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the
queue out of it. Closed: an event that does not satisfy the schema; a kind 31890
signed by anybody but the group, which is somebody else's list and marks nothing;
and an older version of an entry its author has since replaced, since both kinds
are addressable and different relays hold different latest versions, so the merge
that realises an edit happens here, newest per `kind:pubkey:d` with the event id
breaking ties. Added: `isCurated`, true where a canonical entry the group signed
shares the suggestion's identifier -- the coordinate both land on, and the
reference page's rule. Newest first, because that is the order a queue is read in.
**A stranger is named by their profile, and by their key until one arrives.** The
indexer writes a placeholder profile named "LOADING..." the moment a pubkey is
first seen, and a queue full of that would be a queue of nobody.
`suggesterName` reads the profile only when it is a real one -- `createdAt` past
`GENESIS_AT` -- and otherwise the short key, which is at least stable and
distinguishing. The row's avatar is tinted from the key like every avatar here, so
two suggestions by one stranger read as one stranger.
**Read from where the schema says, and the screen says where.** The schema's
`relay` tags are signed into the event for exactly this: a client that finds the
list anywhere knows where its replies live and cannot be sent elsewhere by an
unsigned config. A schema naming none -- the NIP allows it and lets a client pick
-- is read from the group's general relay list, the *read* relays of it, since this
is a read; and failing an agreed, non-empty one, from this build's own relay, the
same fallback the schema editor seeds from. Spellings are normalised so one relay
written two ways is one request. The header names the relays asked, because a
queue read from the wrong relays and a list nobody has written to look exactly
alike from here, and a member should be able to tell them apart.
**Nothing here talks to a relay; it is a pull like every other one-off read.**
`CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay
for the sync pump to drain, carrying the NIP's two queries as one request's two
filters -- everything anyone published in reply to the coordinate, and what the
*group* published in reply to it, the second scoped by `authors` to spare the relay
the work `queueOf` does regardless -- and watches the local table for what comes
back. That watch needed a way to observe an arbitrary NIP-01 filter, which the
repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes
and falls back to text notes for the rest, and none of its shapes is "these kinds
with this `a` tag". `observeNostrEventsMatching` applies the filter whole through
`NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is
anchored and escaped rather than a substring scan; the DAO method behind it names
its observed tables explicitly, the events and the profiles joined onto them, so a
row whose author's kind 0 arrives after the row does re-emits with the name filled
in. What the screen shows is therefore what this device has *stored* for the
coordinate -- which is also what it shows with no network at all, and what it
showed last time until the relays answer again. `NostrEvent.toEvent` is the small
conversion the reading needs to hand a stored row to a verifier written against
the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the
group's own.
**"Still looking" is bounded by time, because nothing else bounds it.** The pump
marks a request sent when the REQ goes out and processed when an event comes back.
A relay holding nothing for the coordinate answers with an EOSE the pump does not
record, so there is no signal for "asked and empty", and a screen that showed the
local table's first, empty read would call every list empty for the second before
its rows arrived -- which teaches a member not to believe it. So the queue is
"being asked" from the request going out until either something lands or ten
seconds pass, and only after that is an empty queue empty. The reference store does
the same with a six-second timer; ten here because the request queues behind the
pump's four subscription slots before it goes out. Rows arriving end it early, a
late arrival after it is still shown, and `withQueue` -- the one state merge --
pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers
"Ask again", which re-queues the same requests: the one thing the watch cannot do
on its own is make more arrive.
**The row shows what an entry is, not where to find it.** The screen is driven by
a schema it has never seen, so what goes on a row is a decision rather than a
layout: the title as the headline, since every list has one; a glance line of the
form fields that say what the entry is, as `Label: value`, because a bare
"2014 · en" says nothing to somebody who does not know the list's fields; the body
where the list has one; and who suggested it, when. Links are left to the sheet --
a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it
a member might act on: it says the group already took this up, and re-curating it
would revise the entry rather than add one.
**The sheet has the whole entry and the event it came as.** Every field the entry
answered, labelled as the schema labels it and in the schema's order, so it reads
as the form would have; the title not repeated, since it is the heading; tokens and
links monospaced because those are compared and copied rather than read. Then the
signed event, byte for byte, with a copy button -- the way the key state sheet
shows the group's own statement and for the same reason: a prettier rendering is a
different string to the one whose id was hashed. It is also the thing a canonical
entry is built from, when that half arrives.
**Four states, and the transition between them.** Loading while the room and the
schema are read; an error, with nothing to retry, for a schema this device does
not hold, since the identifier came from a navigation argument and reading it
again fails the same way; and a loaded state that is either the rows, or looking,
or empty with something to do. `ScreenStateTransition` wraps the `when`, which is
the Scaffold's whole content.
**The audit's proper-noun list grows by two films.** The preview suggests *The Rise
and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and
`m3-title-case.py` excludes sample data by name rather than by pattern.
**Still nothing has reached a relay, and it bites once more.** Group-signed events
are not yet published, so a group's schema has not been where a suggester could
find it, and its queue is empty until it has. The screen reads the coordinate
`31889:<group>:<d>` all the same and shows whatever a relay holds for it; the
suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are
correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at
the reading.
32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion
and canonical entry against the NIP's schema: the reading by field, the minimum
suggestion, the source pointer and its absence, curation not being delegated, a
malformed source, the wrong kind, the reply rules, a missing required field, the
repeat rule both ways, every value rule the doc lists, `require-any`, the closed
and private lists, `checkValue` on durations, patterns and https, and the
coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows:
verified suggestions newest first with the broken, the foreign and the wrong-kind
ones dropped, an edit collapsing to its newest version, the curated mark placed by
the group's canonical entry and not a stranger's, and the naming of a suggester
with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest`
(6) covers the relay order and the read-only filter, one request per relay carrying
both queries and the watch covering both kinds, the three outcomes of `withQueue`,
an `initiate` run end to end against fakes -- the relays asked, the filter watched,
the rows shown, the looking ended -- and a missing schema being an error rather
than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header,
the row's glance line with the link kept off it, the mark on the right row and only
that row, looking against empty, and the sheet with its fields, its link and its
event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the
button under each card in turn and in front of a member with no share.
1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.
Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@1e52fc8f25e29017f1acb72697166ecdfe99f320
2026-09-12 12:54:34 +02:00
<string name= "the_recovery_phrase_sometimes_called_a_seed" > The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good.</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "these_are_your_keys_keep_them_safe_so_they" > These are your keys. Keep them safe so they can keep unlocking this profile and its coins, even when you lose or change your phone.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "this_artifact_has_no_version_for_a" > This artifact has no version for a translation to be of.</string>
<string name= "this_artifact_has_no_version_for_a_chapter" > This artifact has no version for a chapter to attach to.</string>
<string name= "this_chapter_has_no_chunks" > This chapter has no chunks.</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "this_device_holds_no_phrase_for_the_profile" > This device holds no phrase for the profile that is signed in.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "this_decides_who_can_change_the_group_later" > This decides who can change the group later. You can't switch afterwards.</string>
<string name= "this_group_has_no_shared_key_so_it_cannot" > This group has no shared key, so it cannot sign them in.</string>
<string name= "this_group_has_not_been_asked_to_sign" > This group has not been asked to sign anything yet.</string>
<string name= "this_is_fixed_once_the_ceremony_runs" > This is fixed once the ceremony runs. Changing it later means generating a new key.</string>
<string name= "this_is_the_last_thing_the_ceremony_needs" > This is the last thing the ceremony needs from you.</string>
<string name= "this_will_be_shown_when_people_open_the_chat" > This will be shown when people open the chat for more details.</string>
<string name= "this_will_be_shown_when_people_open_your" > This will be shown when people open your profile.</string>
<string name= "this_will_be_the_display_name_for_this_chat" > This will be the display name for this chat room.</string>
<string name= "this_will_be_the_display_name_for_your" > This will be the display name for your profile and also important for search.</string>
feat(identity): list, start and read as an identity that holds no key
Phase 3 of docs/npub-sign-in.md.
listIdentities runs the migration from nostr-keys.dat before its read --
there rather than inside the reader, so a listing that writes is a named
step and not a surprise, and from init, before anything else touches
either file. An old file that cannot be read is left where it is and
reported the way an unreadable credentials file is, so the user sees an
error rather than silently losing every imported identity. The selector
says "read only" under the npub of a public-key identity, before the tap:
two identities can share an avatar and a name, and only one of them will
let the user send a message.
Identity.toKeyPair is now the one place a quartz KeyPair is built from an
identity. With a secret it is the pair as before, the public key a
cross-check; without one it is quartz's read-only constructor. What it
must never be is KeyPair(privKey = null) with nothing else -- that is
quartz's "make me a new key" -- and decryptGiftWrapSeal builds exactly that
from a forwarded pair, which is why the helper has the trap on it.
The synchronization view model runs the two pumps that read for every
identity and the two that write only for one that can sign. Stated as a
rule because Phase 5 leans on it: a read-only identity never sends
anything to a relay, not a signature and not a copy. The broadcast pump
would find nothing, and the live subscriptions are the gift-wrap inbox and
the group-membership follow, both fetching what this identity cannot
open.
One guard in NostrDao.indexNostrEvent, after the isAddressedTo check: a
wrap addressed to a key the pair does not hold is kept, event and wrap,
the way someone else's mail already is. storeNostrEvent is @Transaction
and indexes inside it, so before this the unseal ran with a key nobody has,
threw, and the throw took the event with it while logging as a decryption
failure. Verified both ways: the new DAO test fails on exactly that case
with the guard removed and passes with it, and the same wrap opens once
the pair holds its key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@7db863e3925641bc731816f72bfed90f020566c6
2026-09-12 20:28:57 +02:00
<string name= "read_only" > Read only</string>
feat(ui): what a read-only identity is shown, and what it is not
Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a
product.
One question, asked in one way. LocalCanSign is a composition local for the
snackbar host's reason: screens do not receive the identity,
MetadataEventDetail -- where follow and send message live -- is several
composables below anything that could be handed more, and a parameter
threaded through twenty-six lists is forgotten in the twenty-seventh.
ProvideSigningCapability sits once above the navigation suite. The default
is true rather than an error, the one way this differs from the snackbar
host: the provider cannot be forgotten per screen, so the only things
composed outside it are previews and tests. And it is a capability, not a
kind -- "can this identity sign?", not "is this an npub?" -- so that a
remote signer is not a fourth value in every when.
The inventory, hidden rather than disabled because the empty state beside
each says why: HomeScreen's New chat in both layouts, its sheet and its
npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit
profile and send message (the "follows you" state still shows -- a fact,
not an action); ActiveProfileScreen's key package management and key
recovery; ShareProfileScreen's re-broadcast, which signs nothing but
queues a copy for the finder relays, and a read-only identity puts nothing
on a relay; SocialPreconditionScreen's invite and view invites, pending
today and writes when they exist; and the not-found screen's set-up form,
since a kind 0 has to be signed. Everything else that writes is behind a
chat room, which a read-only identity can never open.
The Messages tab, for a read-only identity, is an EmptyState where the
rooms would be -- one column at every width, since a two-pane layout is a
list beside a detail and there is no list -- whose message says which
absence it is and whose action is the upgrade: Sign in with the nsec,
which opens the same screen as landing's, hits the credentials file's
upgrade rule, and comes back through startup with rooms in it.
ChatRoomListViewModel is not composed, so the inbox sync and the MLS
negentropy it would queue are not queued.
Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens
under each value of LocalCanSign and looks for the controls by text, on
the unmerged tree so that "does not exist" is not vacuous. The M3 audit's
budgets hold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@e31033e857878c6d62830c5fd34918dfb26abfe5
2026-09-12 20:38:18 +02:00
<string name= "messages_need_the_secret_key_this_profile" > Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent.</string>
<string name= "sign_in_with_the_nsec" > Sign in with the nsec</string>
feat(identity): two exits for an identity that holds no key
Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors.
ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out --
the credential out of the file, the metadata hidden, the account rows
gone, in that order so a failure partway leaves the credential on disk
rather than an identity the selector lists but nothing can open. Since the
credentials file the first step is the same call for both credential
kinds, so it is one function; a mnemonic identity is refused at the first
step, because removing a seed is a wallet question this does not answer.
The nsec view model calls it now and keeps only its own state.
Sign out on the profile tab does, for a read-only identity only, what its
colour has been promising: a confirmation naming the npub -- this device
holds no key for it, so there is nothing to lose; the profile stays on the
relays -- then the identity leaves the device and the nav host's tail
re-lists and clears the active identity, which shows the selector or, if
this was the last one, Landing. It is the first real sign out in the app.
For the other two kinds the button keeps its pending route. SignOutViewModel
owns the confirmation and the in-flight state; SignOutDependencies bundles
what the screen needs so that a caller with none of it -- previews, tests
-- passes nothing.
The not-found screen had, for a read-only identity, no exit: Phase 5 hid
the set-up form (a kind 0 has to be signed), the profile tab is not
reachable before ProfileLoaded, and a user whose npub was found on no relay
could try again for ever. So a third action, shown only where the second is
not: use a different key, which is the same sequence behind the same
dialog, reached from the other end of the identity's life.
Tests: the sequence and where it stops, the view model's states around it,
and the two screens composed as each kind -- the profile's sign out asking
first and naming the npub, the not-found screen offering the form to one
kind and the other key to the other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@a586b7c116529f01a4414e40b0eb41f5dff419cc
2026-09-12 20:45:34 +02:00
<string name= "sign_out_of_this_profile_question" > Sign out of this profile?</string>
<string name= "use_a_different_key_question" > Use a different key?</string>
<string name= "this_device_holds_no_key_for_s_so_there" > This device holds no key for %1$s, so there is nothing to lose. The profile stays on the relays, and you can sign in again any time.</string>
<string name= "could_not_sign_out_please_try_again" > Could not sign out. Please try again.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "this_will_give_you_write_access_to_the" > This will give you write access to the profile.</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out
Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.
**Sixteen copies of the same dead end.** The tree held sixteen instances of
Column(horizontalAlignment = CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text("Something went wrong")
}
and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.
`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.
`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.
**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.
`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.
It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.
**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.
**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)
**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name= "torch_will_be_broadcast_what_you_publish_to" > Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "translate_chunk" > Translate chunk</string>
<string name= "translate_into_which_dialect" > Translate into which dialect?</string>
<string name= "translated_text" > Translated text</string>
<string name= "translation" > Translation</string>
<string name= "translation_detail" > Translation detail</string>
<string name= "translations" > Translations</string>
<string name= "transmit_note" > Transmit note</string>
<string name= "trending_notes_functionality_coming_soon_in" > Trending notes functionality coming soon. In the meantime search for what you are looking for.</string>
<string name= "try_again" > Try again</string>
<string name= "type_out_what_you_would_like_to_publish" > Type out what you would like to publish</string>
<string name= "unfollow" > Unfollow</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "unlocking_your_phrase" > Unlocking your phrase…</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "unsupported_event_kind" > Unsupported event kind</string>
<string name= "untitled_article" > Untitled article</string>
<string name= "url" > Url</string>
<string name= "version" > Version</string>
<string name= "versions" > Versions</string>
<string name= "view_and_accept_invites_you_may_have" > View and accept invites you may have received to stay connected with others.</string>
<string name= "view_invites" > View invites</string>
<string name= "waiting_for_you" > Waiting for you</string>
<string name= "waiting_for_your_signature" > Waiting for your signature</string>
<string name= "we_are_looking_for_your_profile_on_as_many" > We are looking for your profile on as many relays as possible. Nostr aims to be decentralized by distributing data to multiple nodse.</string>
<string name= "we_are_searching_the_internet_to_find_your" > We are searching the internet to find your profile and complete sign in.</string>
<string name= "we_couldn_t_find_the_local_profile_please" > We couldn't find the local profile. Please try again later.</string>
<string name= "we_couldn_t_find_your_nostr_event_please_try" > We couldn't find your nostr event. Please try again later.</string>
<string name= "what_s_your_comment_on_the_below" > What's your comment on the below</string>
<string name= "what_s_your_reply_to_the_above" > What's your reply to the above</string>
<string name= "what_should_people_know_about_you" > What should people know about you?</string>
<string name= "what_vibrations_do_you_want_to_send_out" > What vibrations do you want to send out?</string>
<string name= "what_will_be_discussed_in_this_chat_room" > What will be discussed in this chat room.</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "write_down_and_secure_the_12_word_phrase" > Write down and secure the 12 word phrase that this profile and its wallet are derived from.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "who_will_you_be_passing_the_aux_to" > Who will you be passing the aux to?</string>
<string name= "you" > You</string>
<string name= "you_and_1_other" > You and 1 other</string>
<string name= "you_are_about_to_create_a_nostr_profile" > You are about to create a NOSTR profile.</string>
<string name= "you_can_still_carry_on_and_invite_people" > You can still carry on and invite people later.</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "you_have_not_backed_up_your_recovery_phrase" > You have not backed up your recovery phrase</string>
<string name= "you_only_live_once_lose_this_phone_and_the" > You only live once. Lose this phone and the profile goes with it, along with anything it holds.</string>
<string name= "you_said_you_wrote_it_down" > You said you wrote it down</string>
<string name= "yolo" > YOLO</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.
**The extractor took four attempts, and each failure is why it is checked in.**
*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.
*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.
*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:
(if (proposal.eventCount == 2) "event" else "events") +
Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.
**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned
Don\'t sign
backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.
What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.
`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.
**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.
**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.
**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.
**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.
`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name= "you_will_be_in_full_control_of_this_profile" > You will be in full control of this profile. If you would like to use it for the long term please remember to backup the profile/keys.</string>
<string name= "your_profile_is_almost_ready_just_getting_it" > Your profile is almost ready... just getting it's first cryptographic signature together.</string>
<string name= "your_share_of_it_is_on_this_device_only_your" > Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.</string>
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>
2026-09-08 01:45:35 +02:00
<string name= "add_chapter_to" > Add chapter to %1$s</string>
<string name= "add_people_to" > Add people to %1$s</string>
<string name= "chapter" > Chapter %1$s</string>
<string name= "chapter_words_characters" > Chapter %1$s · %2$s words · %3$s characters</string>
<string name= "chunk" > Chunk %1$s</string>
<string name= "chunks" > Chunks (%1$s)</string>
<string name= "chunks_translated" > %1$s/%2$s chunks translated</string>
<string name= "event_s_still_unreadable" > %1$s event(s) still unreadable%2$s</string>
<string name= "events_signed_together" > %1$s events, signed together</string>
<string name= "everyone_has_to_be_online_at_the_same_time" > Everyone has to be online at the same time — the ceremony can only finish once all %1$s of you have taken part.</string>
<string name= "functionality_coming_soon_2" > "%1$s" functionality coming soon</string>
<string name= "how_should_be_run" > How should %1$s be run?</string>
<string name= "invite_2" > Invite %1$s</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out
Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.
**Sixteen copies of the same dead end.** The tree held sixteen instances of
Column(horizontalAlignment = CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text("Something went wrong")
}
and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.
`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.
`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.
**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.
`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.
It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.
**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.
**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)
**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name= "key_packages" > %1$s key packages</string>
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>
2026-09-08 01:45:35 +02:00
<string name= "next_with" > Next with %1$s</string>
<string name= "nostr" > nostr:%1$s..</string>
<string name= "nostr_2" > nostr:%1$s...</string>
<string name= "of" > %1$s of %2$s</string>
<string name= "of_members_will_be_needed_to_sign_with_this" > %1$s of %2$s members will be needed to sign with this key.</string>
<string name= "of_them_could_not_be_read" > %1$s of them could not be read</string>
<string name= "once_invited_will_be_able_to_receive_and" > Once invited will be able to receive and send private message sent to all the %1$s members in the chat room.</string>
<string name= "private_message_to" > Private message to %1$s</string>
<string name= "private_to" > Private to %1$s</string>
<string name= "proposals_are_waiting_for_your_signature" > %1$s proposals are waiting for your signature</string>
<string name= "recovered_of_event_s" > Recovered %1$s of %2$s event(s)%3$s</string>
<string name= "recovered_of_still_unreadable" > Recovered %1$s of %2$s · %3$s still unreadable%4$s</string>
<string name= "reply_privately_to" > Reply privately to %1$s</string>
<string name= "reply_to" > Reply to %1$s</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out
Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.
**Sixteen copies of the same dead end.** The tree held sixteen instances of
Column(horizontalAlignment = CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text("Something went wrong")
}
and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.
`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.
`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.
**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.
`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.
It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.
**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.
**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)
**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name= "replying_to" > Replying to %1$s</string>
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>
2026-09-08 01:45:35 +02:00
<string name= "searching_for_on" > Searching for %1$s on "%2$s"</string>
<string name= "selected" > %1$s selected</string>
<string name= "sent_a_private_message_to" > %1$s sent a private message to %2$s</string>
<string name= "shared_key_for" > Shared key for %1$s</string>
<string name= "to_join_the_chat_room" > to join the %1$s chat room.</string>
<string name= "translate" > Translate %1$s</string>
<string name= "unsupported_event_kind_2" > Unsupported event kind: %1$s</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out
Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.
**Sixteen copies of the same dead end.** The tree held sixteen instances of
Column(horizontalAlignment = CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text("Something went wrong")
}
and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.
`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.
`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.
**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.
`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.
It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.
**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.
**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)
**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name= "was_created_but_couldn_t_be_added_yet_invite" > %1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Mantra.</string>
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>
2026-09-08 01:45:35 +02:00
<string name= "was_created_but_its_shared_key_ceremony" > %1$s was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.</string>
<string name= "words_characters" > %1$s words · %2$s characters</string>
Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:
- ActiveProfileScreen: the entry that phase 4 had externalised as
"Profile keys" is now `key_recovery` in the catalogue, and opens
KeyRecoveryRoute rather than the pending-implementation route. Its
icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
NavHost that now lives under MantraNavigationSuite.
The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.
m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
<string name= "word_position" > #%1$s</string>
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>
2026-09-08 01:45:35 +02:00
<string name= "you_and_others" > You and %1$s others</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out
Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.
**Sixteen copies of the same dead end.** The tree held sixteen instances of
Column(horizontalAlignment = CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text("Something went wrong")
}
and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.
`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.
`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.
**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.
`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.
It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.
**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.
**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)
**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name= "not_following_anyone_yet" > You aren't following anyone yet.</string>
<string name= "nobody_following_you_yet" > Nobody is following you yet.</string>
<string name= "nothing_in_this_feed_yet" > Nothing in this feed yet.</string>
<string name= "no_replies_to_this_yet" > No replies to this yet.</string>
<string name= "nothing_matched_that_search" > Nothing matched that search.</string>
<string name= "key_package_published" > Key package published</string>
<string name= "key_package_rotated" > Key package rotated</string>
feat(subgroups): the four-rung ladder, the picker, and the list a parent reads off its own signatures
Phase 7 of docs/subgroups.md, and the first commit where a user can make a
subgroup. Four pieces.
**A subgroups section on the group detail screen**, above members, listing
`SubgroupManager.subgroupsOf` -- so it shows a child this device holds no room
for, which is the normal position of a member who is not in the subgroup and of
everybody between the certificate being signed and the room being created. A row
titles itself from the *room* where there is one and only otherwise from the
certificate, because the certificate's name and `p` tags are the founding roster
and a renamed or grown subgroup would otherwise be listed under a name nobody
uses. The supporting line says which of the two absences it is: certified but not
created, or created and you are not in it. Tapping opens the child where this
device has it and the certificate where it does not, since that is the whole of
what is known and it is checkable.
**A parent row on the child's detail screen**, directly under the signing key,
because between them they are what the room *is*: the identity it signs as and
whose child it is. It comes off the verified `GroupKeyState.parentChatRoomId`, so
a member welcomed in after the founding sees nothing there rather than an
unverified guess.
**`SelectSubgroupAdminsScreen`**, where the three things that cannot change later
are settled. The pool is the parent's own members, admins and non-admins alike and
marked rather than filtered -- the point of a subgroup is that it can be run by
people the parent does not let run the parent. The coordinator is shown, ticked
and locked, since they hold a share by construction and leaving them off the list
would make "pick two more" read as a group of two.
Key packages are resolved as the screen opens and a member without one is marked
and unselectable. A key package is one-time-use, so every group a member joins
burns one; `MarmotGroupCreation` would refuse to create the room for a missing one
-- correctly, the address being permanent -- but only after a ChillDKG, a parent
quorum and a child quorum had all completed, each needing every selected admin
present. Finding out at the picker costs nothing and finding out at step 4 costs
three ceremonies. The supporting line names the remedy rather than the diagnosis,
because only its owner can publish another.
The quorum stepper is here and nowhere else, and that is a protocol fact: ChillDKG
hashes the threshold and the host keys into the session identity, so `t` is fixed
the moment the proposal goes out. It is also the one value picked for other
people, and consent survives it -- `t` rides on the proposal, `acceptProposal`
re-checks it against `quorumRange`, and the host-key gate is where each invitee
agrees to the `t`-of-`n` they can now see.
**The ritual screen grows a rung rather than being cloned.** `DkgRitualRoute`
takes an optional `parentChatRoomId`, and with one the ladder is four steps
instead of three: key, certificate, key state, room. A parallel subgroup screen
would have duplicated a progress ladder, a threshold picker, three approval gates
and a key-state rung in order to insert one step, and the copies would drift
within a release.
The certificate rung is watched off the *parent's* signed events and sessions
rather than this room's -- it is signed where the parent's key can sign it, which
is never the ceremony's room. The key-state button stays shut until it is done,
because a subgroup's state carries the certificate and `GroupKeyStateManager`
refuses one without it; opening that session early would throw rather than fail.
And `createAdminGroup` passes the verified parent through to the room, names a
subgroup what the coordinator called it rather than "X (#admins)", and says so.
No new approval UI. The certificate is a `FrostSigningEvents.PROPOSAL` in the
parent's room and the key state one in the ceremony room; `ProposalListScreen` and
`FrostSigningScreen` already show and approve both, on both transports.
Six repository methods carry it: `subgroupsOf`, `parentOf`, `canSign` and
`refuseSubgroup` on `ChatRepository`, and `proposeBirthCertificate`,
`observeBirthCertificate` and `proposeSubgroupKeyState` on `DkgRepository`. The
view models talk to repositories and the managers take the database, which is
where the rest of the app has that line. `refuseSubgroup` returns a refusal when
it cannot compute one, because a guard that fails open is not a guard.
25 new strings in the catalogue in sentence case; 397 common tests, 701 jvm tests,
and `m3Audit` meets every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:47:29 +02:00
<string name= "subgroups" > Subgroups</string>
<string name= "add_subgroup" > Add subgroup</string>
<string name= "no_subgroups_have_been_made_by_this_group" > No subgroups have been made by this group.</string>
<string name= "certified_not_yet_created" > Certified, not created yet</string>
<string name= "created_you_are_not_a_member" > Created, you aren't a member</string>
<string name= "a_subgroup" > A subgroup</string>
<string name= "parent_group" > Parent group</string>
<string name= "the_group_this_one_is_a_subgroup_of" > The group this one is a subgroup of</string>
<string name= "run_by_members" > Run by %1$s members</string>
<string name= "new_subgroup" > New subgroup</string>
<string name= "what_is_the_subgroup_called" > What is the subgroup called?</string>
<string name= "who_runs_the_subgroup" > Who runs the subgroup?</string>
<string name= "a_subgroup_needs_at_least_three_admins" > A subgroup needs at least three admins, you included, so pick at least two more people.</string>
<string name= "anyone_in_this_group_can_run_a_subgroup" > Anyone in this group can run a subgroup, whether or not they administer this one.</string>
<string name= "has_no_key_package_yet" > Has no key package yet</string>
<string name= "admin_of_this_group" > Admin of this group</string>
<string name= "you_coordinate_this_subgroup" > You coordinate this subgroup</string>
<string name= "start_the_key_ceremony" > Start the key ceremony</string>
<string name= "checking_who_can_be_added" > Checking who can be added…</string>
<string name= "the_parent_group_has_certified_this_subgroup" > The parent group has certified this subgroup</string>
<string name= "the_parent_group_could_not_certify_this_subgroup" > The parent group could not certify this subgroup</string>
<string name= "the_parent_group_is_certifying_this_subgroup" > The parent group is certifying this subgroup — %1$s of %2$s admins have to sign</string>
<string name= "before_the_subgroup_exists_its_parent_signs" > Before the subgroup exists, its parent signs for it. That signature is what lets anyone check where this group came from.</string>
<string name= "ask_the_parent_group_to_certify" > Ask the parent group to certify this subgroup</string>
<string name= "create_the_subgroup" > Create the subgroup</string>
feat(subgroups): put the ceremony that needs you at the bottom of the chat
A NIP-17 room already showed the standing "waiting for your signature" notice
under the newest message -- `ProposalsAwaitingYouNotice` is transport-agnostic and
reads `FrostSigningSession` by room. What it never covered is the other thing a
room can owe somebody, which in a NIP-17 room is the main thing: a ceremony.
That gap matters more here than the signing one does. A ChillDKG cannot finish
until **every** member has taken part, so one member not finding their request
stalls everyone indefinitely -- and the only way to find it was to scroll the
transcript to its request line, past whatever else the room has been used for.
Three subgroups on the connected devices sat at 1 of 3 host keys for exactly that
reason.
`CeremoniesAwaitingYouNotice` sits beside the signing one, first in the reversed
layout so a room owing both puts the ceremony nearest the composer -- until a
ceremony finishes there is no key to sign anything with.
**It covers two different kinds of owing, and the second has no gate behind it.**
A participant is owed an approval, read through the same `pendingApproval` the
ritual screen uses so the two cannot disagree. The member who *opened* it is owed
something the protocol has no gate for: a ceremony reaching COMPLETE finishes
nothing on its own -- the group has a key and somebody still has to get its state
signed and create the room -- and that somebody is whoever opened it. Nothing else
in the app would ever say so, which is what the coordinator was missing.
`roomAwaitingCreation` is how it knows when to stop: the room a finished ceremony's
key derives either exists or does not. Asking that rather than keeping a flag means
the notice cannot become permanent furniture in every room that has ever held a
ceremony.
**It reads every ceremony in the room, not the newest.** A room holds more than one
the moment a subgroup's admins are the whole group, and the one that wants you is
routinely not the one that happened last -- that is what buried 2.0 and 2.1. The
notice opens the ceremony it names, by session id, through the same
`onOpenSharedKey` the transcript's own lines use since `0b65d702`.
Unlike the signing notice it opens the ceremony rather than a queue: there is no
queue of ceremonies, and with several the count is shown and the newest opened.
Eight strings in the catalogue in sentence case, each step worded the way its
approval screen words it so a member is not asked twice in two vocabularies.
`dkgRepository` is threaded to `ChatMessageListViewModel` through the messaging
screen, the home pane and the nav host.
397 common tests, 718 jvm tests, `m3Audit` meets every budget with 0 title-case
strings and 0 dp literals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:14:11 +02:00
<string name= "the_key_ceremony_needs_you" > The key ceremony needs you</string>
<string name= "the_subgroups_key_ceremony_needs_you" > The subgroup's key ceremony needs you</string>
<string name= "ceremonies_need_you" > %1$s key ceremonies need you</string>
<string name= "join_the_ceremony_by_publishing_your_key" > Join it by publishing your device's key</string>
<string name= "send_your_contribution_to_the_key" > Send your contribution to the key</string>
<string name= "check_the_combined_result" > Check the combined result and confirm it</string>
<string name= "the_group_has_its_key_finish_setting_it_up" > The group has its key — finish setting it up</string>
<string name= "open" > Open</string>
feat(groups): the group's own nostr identity, and the two ways a member could forge it
A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`,
where those are one value -- so every group in this app has had a nostr identity from
the moment it had a key, and has never had a way to say anything about it. This adds
the two statements that identity is made of: a kind:0 saying who the group is, and the
four relay lists saying where it can be found. Both are signed by the group's quorum
like everything else it says.
Two sections on the group's detail screen, between the signing key and the library,
and two editors behind them. Neither editor saves: what leaves them is a
`FrostSigningSession`, and the profile changes on every member's device at once when
enough members sign, or not at all.
**A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.**
`Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a
`NostrEvent`: no member sent it, it never travelled the wire as itself, and the
outbound pipeline re-authors rumors as their sender and would strip the group's
signature off -- which is exactly the position `GroupSignedEvent` exists for. So
`GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are
readings rather than rows. Nothing new is stored: `FrostSigningManager.complete`
already files every signed event before it applies one, so both readers had their
data before this change and neither adds a table, a migration or a DAO method beyond
the two existing kind-scoped queries.
**Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust
model.** The author has to be the room, the id has to be the hash of the fields beside
it, and the signature has to verify -- checkable from the row alone, with no ceremony
or key state to consult. A kind:0 that merely arrived in the room is not the group's
profile; a relay list filed against the room by another group is not the group's relay
list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real
FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's
signature, a forged author and three kinds of rubbish in the signature field are all
tested against the code that actually verifies rather than against a mock of it.
**The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft
did not.** Every kind the group signs needs an arm there or it falls through to
`unsupported` and puts raw JSON in the transcript -- so a profile update would have
appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot
tell them apart: a completed signing session, where the author is the room by
construction, and an arriving inner event, where it is whatever the sender wrote. A rumor
carries an empty signature and any pubkey its sender likes, so the relay arm's original
author-only check would have let one member write "the group signed its general relay
list" into the room's transcript with no group involved. It now builds a
`GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it
goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list
passing through the room fails the author half and gets no line at all, which is right --
it is theirs, not the group's.
**`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types.
The profile and the relay lists are the same kind of statement and the transcript does
the same thing with both -- a `RitualNotice`, answered and settled, because by the time
the line exists a quorum has signed and there is nothing left to do about it. A type
missing from that set renders as a chat bubble, silently, looking exactly like a member
having said "The group is now called Translation collective"; that is why it is a set
with two members rather than two checks.
**Relay lists: four sets, and the two that were left out are the interesting part.**
General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked
(NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not
here even though this app writes one for every person: a key package is a device's offer
to be added to an MLS group, and a group has no device and joins nothing, so a group
advertising where its key packages live would point at an address that is permanently
empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason
below.
**A group signs and cannot decrypt, so every list is written in public tags.** NIP-51
puts a relay list's entries in the encrypted half by default, and a blocked list
especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to
the author's own key and there is no ECDH for a FROST threshold key here. That rules out
10012 entirely -- its list is only ever private -- and it means the group's blocked list
is public where a person's client would keep it secret. Said in the code, and said on the
Blocked tab where somebody is about to act on it.
**The editor proposes every list that changed at once, which is the deliberate departure
from the interface it copies.** Wisp publishes the tab in front of you, because a person
signs for themselves and there is nothing to coordinate. Here each signature costs a
quorum's attention, and four sessions for one sitting at one screen would ask for it four
times over what is plainly one decision. `changedSets` is what keeps that honest, and it
is the piece with a quiet failure on both sides: too eager and every visit re-signs four
untouched lists, dating a decision the group did not make; too shy and an edit is dropped
on a screen that said it had been sent. It compares in order, because a relay list is
written in the order it is read back and a member who moved a relay to the front meant to;
it counts a seeded first-time list as a change, so a group whose editor filled itself in
will actually send it; and it counts an empty unagreed set as no change, so opening
Blocked and pressing the button does not put a quorum's signature over silence. Nine
cases in `EditGroupRelaysViewModelJvmTest`.
**A relay that is neither read nor write is a state NIP-65 cannot express**, so it must
not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read`
for read-only and `write` for write-only, and has nothing for neither -- what neither
would mean is that the relay is not in the list, and removing it is the row's own button.
The toggles refuse to turn off the last marker and `template` drops such a relay as a
second line of defence, because a bare tag written for it would advertise it as *both*,
which is the opposite of what was asked. The round trip is tested for all three markers
at once: the ordinary case is the dangerous one, since "both" is encoded by the third
element being *absent*, so a reader that dropped it entirely would look correct for a
both-ways relay and silently promote every read-only one.
**`ephemeral.mantra.press` is what a first-time group is seeded with**, for General,
Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish,
finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to
talk to somebody that nobody in the group chose.
**A profile edit builds on the group's last one; a relay list does not.** `template` for
the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not
offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead
of being dropped by it. Using `createNew` there would compile, pass any test that only
looked at the six fields on the form, and quietly wipe the rest every time somebody fixed
a typo in the group's name. A relay list is the opposite: it is one list, replacing it is
the whole point of signing a new one, and carrying a tag forward would make removal
impossible. Both are tested for the behaviour that would be silent if wrong.
**The name is written to both `name` and `display_name`.** They are the two fields readers
pick a name out of and they disagree at their peril: a group whose `display_name` came
from some other tool and whose `name` was edited here would keep answering to the old one
in every client preferring `display_name` -- an edit that visibly does not take. One field
on the form, both keys in the event.
**Blank clears, and that is the only way to unsay something a group has signed.**
`MetadataEvent`'s rule for these arguments is that an empty string removes the key and
null leaves it alone, so the form sends what its fields hold. Said on the screen, because
a member emptying a field is entitled to know whether it will be ignored.
**Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather
than a key it can sign as, so it has no nostr identity and never will; an empty section
there would promise something that is not coming. Every other room shows the sections and
gates only the two edit buttons on holding a share of the key -- the profile and the relay
lists are worth reading whoever is looking, and only a share-holder can open a session
about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry
points and the subgroup one, since it walks the room's ceremonies looking for a share and
asking three times would do the same walk three times for one answer.
**"Never agreed" and "agreed empty" are different states and the summary says which.**
A set the group has never signed reads "not set yet"; one it signed empty reads "no
relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and
`GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so
the screen has a row per set either way.
**The editor's working copy is seeded from the screen, not from the loader**, and that is
a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path
that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an
empty editor while the app worked fine, which is the shape of bug that survives review
because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy`
is idempotent and fills only missing keys, so the effect that calls it can re-run without
throwing away typing.
**`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since
a group has no `Profile` row to hand it and the picture and the name were all it ever
wanted from one. The two existing overloads delegate to it, so there is one avatar rather
than a second one for groups.
**What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete`
applies signed events locally and puts nothing on the wire, for the reason its comment
gives. So the profile and the lists are visible to members and to nobody else, and the
relay lists say where the group's work *should* go without anything yet sending it there.
Publishing would mean a `NostrEvent` row for an event no member authored, which is an
architectural decision and belongs in its own change. Said at the top of both readers so
the next reader does not assume otherwise.
**And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that
deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds
that a validly signed old one replayed by whoever kept a copy is a statement nobody can
refuse. These five kinds are in the same position, so a member who joins after a profile
is signed will not be handed it in their catch-up. Adding them is a decision with its own
trade and is not made here.
48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape
`\'`, so the aapt spelling would render the backslash.
1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`,
41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets
every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@4c0ed0c1ceeca5aba461fbca314821befc12c583
2026-09-10 11:30:53 +02:00
<string name= "signed_by_the_group_on" > Signed by the group on %1$s</string>
<string name= "relays" > Relays</string>
<string name= "relay_url" > Relay url</string>
<string name= "eg_wss_relay_example_com" > eg. wss://relay.example.com</string>
<string name= "add_relay" > Add relay</string>
<string name= "that_is_not_a_relay_address" > That isn't a relay address. Relays start with wss:// and cannot be on this device.</string>
<string name= "that_relay_is_already_in_this_list" > That relay is already in this list.</string>
feat(groups): a broadcast button under each signed event, and the relays it goes to
Until now nothing a group signed reached a relay. `FrostSigningManager.complete`
files the batch as `GroupSignedEvent` rows and applies it locally, and the profile,
the relay lists, the posts and the schemas are all read off those rows -- the docs
on each said so, and the only way out was the copy button on the key-state sheet.
This is the way out: a "Broadcast" button under every signed event in the identity
block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits
the relays it will go to, presses once, and reads what each relay answered. The raw
JSON is at the bottom for anyone who would rather use another tool.
**The send goes to the relay pool directly, not through the broadcast queue.**
Everything the app publishes as itself goes through `BroadcastNostrEventRequest`,
which is durable and retried, and it is not used here on purpose. The queue hangs
off a `NostrEvent` row, and a row for the group's event is more than a queue entry:
the home feed selects by kind, so the group's post would appear in it as anybody's;
and the sync loop offers every local event id to the relays it reconciles with, so
the event would reach relays the screen never named -- which would make "edit the
relays" a fiction. `GroupSignedEvent` already explains at length why it is not a
`NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of
`RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is
and for the same reason: the manager cannot be stood up in a test. The cost is that
a broadcast is only as durable as the button press. Each row says what its relay
said, and a relay that did not answer is sent to again by pressing again.
**Behind no gate.** Every other button in the block is hidden from a member holding
no share of the key, because each proposes a signature by the group. Sending what
the group has already signed takes no share: the signature is the group's whoever
repeats it, and any member could paste the JSON into another client today. So the
button is there for whoever is looking, the way the suggestion queue is.
**Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's
General list where it has agreed one -- its write relays, and nothing from the app's
set beside them, since a member who edited the relays meant them -- and the app's
publish set where it has not. A relay list also goes to the indexers, because a list
sent only to the relays it names is circular; a curated schema also goes to the
relays it names for its own replies, so a client reading suggestions there finds
what they answer. Nothing goes to a relay the group has blocked. The seed happens
once, so a list the member emptied stays empty.
**Each relay's answer is shown in the relay's words.** The pool answers once per
relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and
any other error -- and the rows keep them apart, because "blocked: not on the allow
list" is something a member can act on and a red icon is not. A relay still
unanswered when the whole send runs out of time reads "No answer", which is not the
same as refused. Answers are matched by host, since the pool names a relay by the
URL it first opened a socket with.
The relay-list rows get their button inside the shared card, under the row and at
the end, because the four rows share one card and "under the event" has to mean
under the row. An agreed empty list gets one too: a withdrawal is a statement, and
relays still holding the old list need to hear it. The key-state event and the
subgroup certificates get none -- `ChronicleManager` keeps the key state off even
the members-only path, and a public relay is a bigger audience than that.
`BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only
seeding, the three answer shapes and the host matching, and that what goes on the
wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest`
presses the button against a fake pool and finds each answer under its relay with
the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group
with a profile, two agreed lists, a post and a schema, each under its own card, and
finds them still there for a member with no share.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@6ae5a9676eb01fcda49024caffff1a8bdc335831
2026-09-12 13:55:29 +02:00
<!-- Broadcasting one event the group signed to relays a member picks. -->
<string name= "broadcast" > Broadcast</string>
<string name= "sends_the_event_exactly_as_the_group_signed_it" > Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours.</string>
refactor(groups): take out the group's nostr identity and the curated lists, and keep broadcast unreachable
Lines B, D and H of docs/curated-to-mantra.md, pulled from the Curated fork this
morning, go again this afternoon: the group's nostr profile (kind 0), its four relay
lists (NIP-65, NIP-17, NIP-50, NIP-51), its posts (kind 1), the curated schemas it
publishes (31889), the suggestions it reads (31888) and the entries it accepts
(31890). Eighty-two files, twelve screens, the `nostr/curated/` package, the readings
(`GroupNostrProfile`, `GroupRelayList`, `GroupPost`, `GroupCuratedSchema`,
`GroupCuratedEntry`, `CuratedSuggestion`), the `applyInnerEvent` arms for all eight
kinds, the `ChatRepository` and `NostrRepository` reads that fed them, 226 strings,
and every test that came with them. A translation collective has no list to curate
and no reason to describe itself under a key no member controls, and five rows
saying so on every group's screen were five rows about somebody else's product.
**The pasted-event proposal goes with them, because it was theirs.**
`GroupEventProposal.ACCEPTED_KINDS` was exactly kinds 0, 1, the four relay-list
kinds and 31889 -- "the kinds the group's screen has a place for", in its own words --
so with the five rows gone it would have refused every paste. Keeping it as a
generic "sign any event" was considered and rejected: the screen's whole argument
was that a member goes to the row to see the event landed, and there is no row. The
`ProposedEvent` summaries for the same kinds go too; the one test of its pre-existing
fallback ("Event of kind N") is kept, as the only line of `ProposedEventTest` that
was about code this repository had before the pull.
**Broadcast stays, and nothing opens it.** `BroadcastGroupSignedEventScreen`, its
route, view model, state and `BroadcastButton` are kept, on the decision that a way
to send a group-signed event to relays is worth having against the day something
wires a button in -- the button's only call sites were the five removed screens.
Two things had to change for it to compile against a tree with no relay lists:
`defaultRelaysFor(kind)` is now the app's own publish set for every kind, since the
General list it preferred, the Blocked list it filtered by and the schema relays it
widened to no longer exist; and `relayUrlOrNull` moves into the view model's
companion from the deleted `GroupRelaySet`, unchanged. The hint on the screen says
"the relays this app publishes to" rather than "where the group has said it lives".
Its two tests are rewritten around the new seed: the screen test answers for the
first two seeded relays and counts the rest as asked, and empties the list by hand
to see the empty state, since the seed always has something in it. Deleting
broadcast outright -- the tidier tree -- was the recommendation and was declined.
**Every pre-existing file is back at its pre-pull content plus the kept lines'
hunks, and nothing else.** Eleven files -- `ChatMessage.kt`, `NostrEventDao.kt`,
`NostrEvent.kt`, `LocalChatRoom.kt`, `Member.kt`, `ProposedEvent.kt`,
`ChatTranscript.kt`, `ProfileAvatar.kt`, the group screen's view model and state,
and `m3-title-case.py` -- were touched by no kept commit and are restored from
ba0830a3 byte for byte, so `inComparableGroups` is a private helper of the group
screen again rather than a shared extension one deleted screen needed, and
`ProfileAvatar` has one overload again. The rest were restored and had the kept
hunks re-applied: the back button's three on `ChatRoomDetailScreen`, broadcast's
`groupSignedEvent` read on the two chat repositories, and on the two nostr
repositories the sign-in reads, by reverse-applying the queue commit's hunk. The
check is `git diff ba0830a3 -- <file>`, which shows only those. Reverting the eight
commits was rejected because the back button and the read-only identity work landed
on top of them and would have conflicted in every one of the shared files; editing
the current files by hand was rejected because it leaves residue that a diff against
the base cannot distinguish from a decision.
**The strings that went are exactly the ones nothing references any more and that
the pull added.** Six strings were unreferenced before the pull and stay; the header
sentence and one capitalised "Mantra" that the queue commit's join carried are prose,
not feature, and stay too. The seven section comments that described removed blocks
go; the broadcast block's stays.
**The plan's third decision said "hide the two rows behind a constant".** That
covered the curated rows and not the profile, relays and posts beside them, and the
call was to take the whole identity block out. The record of what went and what
stayed is the paragraph after the built table in docs/curated-to-mantra.md, with the
seam it leaves for the next pull: an upstream commit that touches the identity block
conflicts at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and is
dropped. docs/README.md says the same in a sentence. The nsec and npub notes still
name `EditGroupCuratedSchemaViewModel`; they are records of what was built upstream
and are left as written.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:compileKotlinJvm,
:composeApp:jvmTest (826 tests, from 1,039), :composeApp:testDebugUnitTest (413,
from 530) and :composeApp:m3Audit, every budget met, 12 adaptive uses at the floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:45:15 +02:00
<string name= "the_relays_this_app_publishes_to_are_filled_in" > The relays this app publishes to are filled in. Add or remove relays before sending.</string>
feat(groups): a broadcast button under each signed event, and the relays it goes to
Until now nothing a group signed reached a relay. `FrostSigningManager.complete`
files the batch as `GroupSignedEvent` rows and applies it locally, and the profile,
the relay lists, the posts and the schemas are all read off those rows -- the docs
on each said so, and the only way out was the copy button on the key-state sheet.
This is the way out: a "Broadcast" button under every signed event in the identity
block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits
the relays it will go to, presses once, and reads what each relay answered. The raw
JSON is at the bottom for anyone who would rather use another tool.
**The send goes to the relay pool directly, not through the broadcast queue.**
Everything the app publishes as itself goes through `BroadcastNostrEventRequest`,
which is durable and retried, and it is not used here on purpose. The queue hangs
off a `NostrEvent` row, and a row for the group's event is more than a queue entry:
the home feed selects by kind, so the group's post would appear in it as anybody's;
and the sync loop offers every local event id to the relays it reconciles with, so
the event would reach relays the screen never named -- which would make "edit the
relays" a fiction. `GroupSignedEvent` already explains at length why it is not a
`NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of
`RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is
and for the same reason: the manager cannot be stood up in a test. The cost is that
a broadcast is only as durable as the button press. Each row says what its relay
said, and a relay that did not answer is sent to again by pressing again.
**Behind no gate.** Every other button in the block is hidden from a member holding
no share of the key, because each proposes a signature by the group. Sending what
the group has already signed takes no share: the signature is the group's whoever
repeats it, and any member could paste the JSON into another client today. So the
button is there for whoever is looking, the way the suggestion queue is.
**Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's
General list where it has agreed one -- its write relays, and nothing from the app's
set beside them, since a member who edited the relays meant them -- and the app's
publish set where it has not. A relay list also goes to the indexers, because a list
sent only to the relays it names is circular; a curated schema also goes to the
relays it names for its own replies, so a client reading suggestions there finds
what they answer. Nothing goes to a relay the group has blocked. The seed happens
once, so a list the member emptied stays empty.
**Each relay's answer is shown in the relay's words.** The pool answers once per
relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and
any other error -- and the rows keep them apart, because "blocked: not on the allow
list" is something a member can act on and a red icon is not. A relay still
unanswered when the whole send runs out of time reads "No answer", which is not the
same as refused. Answers are matched by host, since the pool names a relay by the
URL it first opened a socket with.
The relay-list rows get their button inside the shared card, under the row and at
the end, because the four rows share one card and "under the event" has to mean
under the row. An agreed empty list gets one too: a withdrawal is a statement, and
relays still holding the old list need to hear it. The key-state event and the
subgroup certificates get none -- `ChronicleManager` keeps the key state off even
the members-only path, and a public relay is a bigger audience than that.
`BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only
seeding, the three answer shapes and the host matching, and that what goes on the
wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest`
presses the button against a fake pool and finds each answer under its relay with
the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group
with a profile, two agreed lists, a post and a schema, each under its own card, and
finds them still there for a member with no share.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@6ae5a9676eb01fcda49024caffff1a8bdc335831
2026-09-12 13:55:29 +02:00
<string name= "no_relays_to_broadcast_to" > No relays to broadcast to. Add one above.</string>
<string name= "add_a_relay_to_broadcast_to" > Add a relay to broadcast to.</string>
<string name= "relay_sending" > Sending…</string>
<string name= "relay_accepted" > Accepted</string>
<string name= "relay_rejected" > Rejected: %1$s</string>
<string name= "relay_could_not_send" > Couldn't send: %1$s</string>
<string name= "relay_no_answer" > No answer</string>
<string name= "accepted_by_n_of_m_relays" > Accepted by %1$s of %2$s relays.</string>
<string name= "copy_raw_json" > Copy raw JSON</string>
<string name= "copied_the_raw_json" > Copied the raw JSON</string>
feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written
Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence
about alpha testing and becomes the screen: one field, two things it accepts,
and the npub it signs as shown before anything is written.
One field, because a user does not choose an input type -- they paste what they
have -- and the two shapes are unambiguous: words have spaces, keys do not.
CredentialParser is that decision as a pure function. Words go through
MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed
nsec or sixty-four hex characters decode to a PrivateKey and must pass
isValid(). The two shapes a reasonable person might paste and that cannot
work are refused by name rather than lumped in with garbage: an npub is
PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey
(NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is,
and a capital is a paste artefact worth showing rather than silently fixing.
Problems with the input stay on the field, as supporting text, while the
prompt is still showing; problems after confirming -- the key was already
here, the write failed -- are a state of the screen, through ErrorState with
a retry back to the field. Four states, wrapped in ScreenStateTransition.
Confirm shows the npub in full, in monospace, and which kind was recognised,
with the one consequence that differs: a recovery phrase also restores the
wallet, a bare key comes with none. The secret itself is never echoed.
SignInToProfileViewModel.commit is the effect, and its order is the point.
The secret is written to its store first -- IdentityWriter.writeNostrKey for
an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a
phrase, both injected as functions so the commit can be driven without a key
store -- and only on success is the placeholder account planted with
nostrRepository.signInToProfile. The placeholder before the write would send
the user to fetch a profile they can never sign for; the write before the
placeholder is what the nav host then does. Both writers already refuse a
duplicate by nostr public key, so a wallet's own nostr secret pasted as an
nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare.
The nav host wires the route with the same tail the create flow uses after
writeSeed: re-list identities, select the new one, go to startup with the form
popped, so back does not return to a field holding a secret. Startup finds
the identity, activates it, and NavigationViewModel takes it from the
placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from
both entrances.
Strings: nineteen new, sentence case, including the refusals as sentences
that say what to do instead. The six Torch-era sign-in strings nothing
referenced any more are gone, and the landing caption no longer promises a
remote signer this does not deliver.
Tests. CredentialParserJvmTest is every row of the table in and out, all
three spellings of NIP-06's vector -- words, nsec, hex, with and without the
nostr: prefix, in either case -- asserted to land on the same public key,
which is the equality the duplicate check rests on; and each refusal by name.
SignInToProfileViewModelJvmTest records the effects of a commit and asserts
their order and count: write then plant for both kinds, and no planting after
a refusal or a throw.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885
tests) and :composeApp:m3Audit, which found no spacing literal or title-case
string in the new screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@220616019e6466a3fb36a5bf2b482f20d93c01c5
2026-09-12 12:05:35 +02:00
<!-- Sign in: docs/nsec - sign - in.md, phase 4. -->
<string name= "a_password_protected_key_ncryptsec_is_not" > A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec.</string>
<string name= "enter_a_recovery_phrase_or_an_nsec_to_sign_in" > Enter a recovery phrase or an nsec to sign in.</string>
<string name= "paste" > Paste</string>
<string name= "recognised_as_a_nostr_secret_key_no_wallet" > Recognised as a nostr secret key. No wallet comes with it.</string>
<string name= "recognised_as_a_recovery_phrase_it_also" > Recognised as a recovery phrase. It also restores the wallet.</string>
feat(sign-in): an npub, recognised, confirmed as read-only, then written
Phase 4 of docs/npub-sign-in.md. The field accepts a third thing.
CredentialParser recognises npub1... and nostr:npub1... as
SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix
that name a point on the curve -- the counterpart of the isValid() a
secret is checked with, since not every x coordinate has a point above it.
PublicKeyOnly goes, and its string with it; an npub that does not decode,
or names no point, is InvalidKey. Hex stays a secret: a private key and an
x-only public key are the same size, the confirm step shows the derived
npub so a public key pasted as hex is visible for what it is, and guessing
would never fire when it mattered, since an x coordinate is almost always
also a valid scalar. The test pins the vector's own public key, pasted as
hex, landing on a different npub.
The confirm step's third arm says what the user is about to get and not
get, once, before the choice: this profile and the people it follows;
messages closed and nothing sent; paste the nsec later to open it. The
landing caption, the field's label and its placeholder name the third
input, the last with "to look around" for a user who does not know the
word read-only. The view model takes the third writer the way it takes the
other two and commits through one shared write-and-map.
signInToProfile is idempotent by pubkey. Until now nothing called it twice
for one key -- the writers refused a second sign-in before it got there.
Pasting the nsec of a key held read-only is the first path that signs in a
pubkey whose account already exists, and a second placeholder would be two
kind-0 rows for one pubkey, two accounts disagreeing about which is this
one. A repository test signs in twice and counts one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@bfad1f39a2030ccf52cee7bf390bb9d68d33e016
2026-09-12 20:32:54 +02:00
<string name= "recognised_as_a_public_key_you_will_see" > Recognised as a public key. You will see this profile and the people it follows; messages stay closed and nothing can be sent. Paste the nsec later to open it.</string>
<string name= "recovery_phrase_nsec_or_npub" > Recovery phrase, nsec or npub</string>
<string name= "sign_in_with_a_recovery_phrase_an_nsec_or" > Sign in with a recovery phrase, an nsec, or an npub to look around</string>
feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written
Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence
about alpha testing and becomes the screen: one field, two things it accepts,
and the npub it signs as shown before anything is written.
One field, because a user does not choose an input type -- they paste what they
have -- and the two shapes are unambiguous: words have spaces, keys do not.
CredentialParser is that decision as a pure function. Words go through
MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed
nsec or sixty-four hex characters decode to a PrivateKey and must pass
isValid(). The two shapes a reasonable person might paste and that cannot
work are refused by name rather than lumped in with garbage: an npub is
PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey
(NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is,
and a capital is a paste artefact worth showing rather than silently fixing.
Problems with the input stay on the field, as supporting text, while the
prompt is still showing; problems after confirming -- the key was already
here, the write failed -- are a state of the screen, through ErrorState with
a retry back to the field. Four states, wrapped in ScreenStateTransition.
Confirm shows the npub in full, in monospace, and which kind was recognised,
with the one consequence that differs: a recovery phrase also restores the
wallet, a bare key comes with none. The secret itself is never echoed.
SignInToProfileViewModel.commit is the effect, and its order is the point.
The secret is written to its store first -- IdentityWriter.writeNostrKey for
an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a
phrase, both injected as functions so the commit can be driven without a key
store -- and only on success is the placeholder account planted with
nostrRepository.signInToProfile. The placeholder before the write would send
the user to fetch a profile they can never sign for; the write before the
placeholder is what the nav host then does. Both writers already refuse a
duplicate by nostr public key, so a wallet's own nostr secret pasted as an
nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare.
The nav host wires the route with the same tail the create flow uses after
writeSeed: re-list identities, select the new one, go to startup with the form
popped, so back does not return to a field holding a secret. Startup finds
the identity, activates it, and NavigationViewModel takes it from the
placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from
both entrances.
Strings: nineteen new, sentence case, including the refusals as sentences
that say what to do instead. The six Torch-era sign-in strings nothing
referenced any more are gone, and the landing caption no longer promises a
remote signer this does not deliver.
Tests. CredentialParserJvmTest is every row of the table in and out, all
three spellings of NIP-06's vector -- words, nsec, hex, with and without the
nostr: prefix, in either case -- asserted to land on the same public key,
which is the equality the duplicate check rests on; and each refusal by name.
SignInToProfileViewModelJvmTest records the effects of a commit and asserts
their order and count: write then plant for both kinds, and no planting after
a refusal or a throw.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885
tests) and :composeApp:m3Audit, which found no spacing literal or title-case
string in the new screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@220616019e6466a3fb36a5bf2b482f20d93c01c5
2026-09-12 12:05:35 +02:00
<string name= "signed_in" > Signed in</string>
<string name= "signing_you_in" > Signing you in…</string>
<string name= "that_is_not_a_recovery_phrase_or_a_nostr_key" > That is not a recovery phrase or a nostr key.</string>
<string name= "that_nostr_secret_key_is_not_valid" > That nostr secret key is not valid.</string>
<string name= "that_recovery_phrase_is_not_valid_check" > That recovery phrase is not valid. Check each word and the order.</string>
<string name= "the_key_could_not_be_saved_on_this_device" > The key could not be saved on this device.</string>
<string name= "the_words_or_the_key_never_leave_this_device" > Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm.</string>
<string name= "this_key_is_already_on_this_device" > This key is already on this device.</string>
feat(sign-in): an npub, recognised, confirmed as read-only, then written
Phase 4 of docs/npub-sign-in.md. The field accepts a third thing.
CredentialParser recognises npub1... and nostr:npub1... as
SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix
that name a point on the curve -- the counterpart of the isValid() a
secret is checked with, since not every x coordinate has a point above it.
PublicKeyOnly goes, and its string with it; an npub that does not decode,
or names no point, is InvalidKey. Hex stays a secret: a private key and an
x-only public key are the same size, the confirm step shows the derived
npub so a public key pasted as hex is visible for what it is, and guessing
would never fire when it mattered, since an x coordinate is almost always
also a valid scalar. The test pins the vector's own public key, pasted as
hex, landing on a different npub.
The confirm step's third arm says what the user is about to get and not
get, once, before the choice: this profile and the people it follows;
messages closed and nothing sent; paste the nsec later to open it. The
landing caption, the field's label and its placeholder name the third
input, the last with "to look around" for a user who does not know the
word read-only. The view model takes the third writer the way it takes the
other two and commits through one shared write-and-map.
signInToProfile is idempotent by pubkey. Until now nothing called it twice
for one key -- the writers refused a second sign-in before it got there.
Pasting the nsec of a key held read-only is the first path that signs in a
pubkey whose account already exists, and a second placeholder would be two
kind-0 rows for one pubkey, two accounts disagreeing about which is this
one. A repository test signs in twice and counts one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@bfad1f39a2030ccf52cee7bf390bb9d68d33e016
2026-09-12 20:32:54 +02:00
<string name= "twelve_words_nsec1_or_npub1" > Twelve words, nsec1… or npub1…</string>
feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written
Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence
about alpha testing and becomes the screen: one field, two things it accepts,
and the npub it signs as shown before anything is written.
One field, because a user does not choose an input type -- they paste what they
have -- and the two shapes are unambiguous: words have spaces, keys do not.
CredentialParser is that decision as a pure function. Words go through
MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed
nsec or sixty-four hex characters decode to a PrivateKey and must pass
isValid(). The two shapes a reasonable person might paste and that cannot
work are refused by name rather than lumped in with garbage: an npub is
PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey
(NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is,
and a capital is a paste artefact worth showing rather than silently fixing.
Problems with the input stay on the field, as supporting text, while the
prompt is still showing; problems after confirming -- the key was already
here, the write failed -- are a state of the screen, through ErrorState with
a retry back to the field. Four states, wrapped in ScreenStateTransition.
Confirm shows the npub in full, in monospace, and which kind was recognised,
with the one consequence that differs: a recovery phrase also restores the
wallet, a bare key comes with none. The secret itself is never echoed.
SignInToProfileViewModel.commit is the effect, and its order is the point.
The secret is written to its store first -- IdentityWriter.writeNostrKey for
an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a
phrase, both injected as functions so the commit can be driven without a key
store -- and only on success is the placeholder account planted with
nostrRepository.signInToProfile. The placeholder before the write would send
the user to fetch a profile they can never sign for; the write before the
placeholder is what the nav host then does. Both writers already refuse a
duplicate by nostr public key, so a wallet's own nostr secret pasted as an
nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare.
The nav host wires the route with the same tail the create flow uses after
writeSeed: re-list identities, select the new one, go to startup with the form
popped, so back does not return to a field holding a secret. Startup finds
the identity, activates it, and NavigationViewModel takes it from the
placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from
both entrances.
Strings: nineteen new, sentence case, including the refusals as sentences
that say what to do instead. The six Torch-era sign-in strings nothing
referenced any more are gone, and the landing caption no longer promises a
remote signer this does not deliver.
Tests. CredentialParserJvmTest is every row of the table in and out, all
three spellings of NIP-06's vector -- words, nsec, hex, with and without the
nostr: prefix, in either case -- asserted to land on the same public key,
which is the equality the duplicate check rests on; and each refusal by name.
SignInToProfileViewModelJvmTest records the effects of a commit and asserts
their order and count: write then plant for both kinds, and no planting after
a refusal or a throw.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885
tests) and :composeApp:m3Audit, which found no spacing literal or title-case
string in the new screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@220616019e6466a3fb36a5bf2b482f20d93c01c5
2026-09-12 12:05:35 +02:00
<string name= "use_a_different_key" > Use a different key</string>
<string name= "you_are_signing_in_as" > You are signing in as</string>
feat(sign-in): ask the relays that would know, and say when none of them did
Phase 5 of docs/nsec-sign-in.md. Not nsec-specific: a restored recovery phrase
whose profile was never published, or a device offline at sign-in, sat on the
same spinner. An imported nsec is the first path that hits it routinely --
many nostr keys are made in a client that never wrote a kind 0.
Ask the relays that would know. The sign-in sync fanned its REQ out over
Relays.DefaultDMRelayList, which is listOf(ephemeral): our own relay, alone.
The right answer for a profile this app created; an identity that has lived
on Damus for three years has never heard of it. SignInSync now holds the one
definition three call sites want -- the kinds, the request builder and the
bootstrap set, which is every indexer relay plus ours. Indexer relays exist
to hold everyone's kinds 0, 3 and 10002; that is exactly what the sync asks
for.
Then follow the answer, once. When a level-0 sign-in request brings back the
user's own kind 10002, the sync pump queues the same request at level 1 at
the relays that list says they write to, less the bootstrap set already
asked. The outbox model doing what it is for: the indexers know *where* the
user publishes, and the user's own relays are where the rest of their lists
are authoritative. Write relays, not read relays -- asking a user's inbox for
their own events is the mistake the split exists to name. One hop per
account per session, because every bootstrap relay that holds the list
answers with it; and a level-1 request never re-enters, because past the
user's own relays lies the feed, not the profile.
Say when none of them did. A request went pending -> sent when its REQ was
dispatched and sent -> processed only if an event arrived for it; a relay
that answered with EOSE and nothing else left its row at `sent` for good, so
"still searching" and "searched, found nothing" were the same row and the
screen waiting on the sync had no way to say the second thing. The pump's
finally block -- every exit: EOSE, CLOSED, the bounded timeout -- now records
`complete`, conditionally in SQL on the row still being `sent`, because the
event handler that writes `processed` runs in its own coroutine and can land
after the subscription has closed, and a completion that overwrote it would
turn "found" back into "found nothing".
UnsyncedProfileViewModel reads that. Its one decision, `decide`, is a pure
function of the account: a kind 0 indexed or a Profile row means the route is
about to move, so still searching; any request still pending or sent is a
relay that has not finished; only when every request has finished and
nothing came is the answer not-found. The screen's not-found state offers
two exits. Try again re-queues the bootstrap, and the new in-flight requests
put the screen back to searching on their own. Set up a profile is the
six-event bootstrap a fresh key gets, without a fresh key:
setUpProfileForExistingKey writes the kind 0 *over* the placeholder row,
under its own id with signedAt back to null, rather than beside it --
getLocalAccounts is every kind-0 row, and two for one pubkey would be two
accounts disagreeing about which is this one. The rows change under
observeLocalAccount, NavigationViewModel sees an unsigned kind 0, and the
notary, which already holds this key, signs it. createNewProfile now builds
its events through the same bootstrapUnsignedEvents.
Tests. SignInSyncTest pins the bootstrap set (every indexer, ours, no
duplicates, more than one) and the outbox reading of a relay list: write and
unmarked relays in, read relays and malformed tags out, already-asked
removed. UnsyncedProfileDecisionTest walks pending -> sent -> complete and
asserts the answer flips only on the last, that a request answered with
something other than a profile still counts as finished, that an arrived
kind 0 is never not-found. SynchronizeNostrEventRequestCompletionJvmTest
pins the conditional update against a real Room database: sent becomes
complete, processed and pending are left alone. SetUpProfileForExistingKeyJvmTest
runs signInToProfile then the set-up against the repository and asserts one
kind-0 row, the same id, unsigned, carrying the name, with five events beside
it.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (895
tests) and :composeApp:m3Audit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@d61d3560a090c35b8002fa73d0e24b863002e33e
2026-09-12 12:16:06 +02:00
<!-- Sign in: the search that found nothing, docs/nsec - sign - in.md, phase 5. -->
<string name= "enter_a_name_for_the_profile" > Enter a name for the profile.</string>
<string name= "it_may_be_new_or_it_may_live_on_relays_we" > It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here.</string>
<string name= "set_up_a_profile" > Set up a profile</string>
<string name= "we_could_not_find_a_profile_for_this_key" > We could not find a profile for this key on the relays we asked.</string>
feat(recovery): the key behind an identity that has no phrase, and the way to forget it
Phase 6 of docs/nsec-sign-in.md. KeyRecoveryScreen offered one backup, the
recovery phrase, whose screen reads userWallet.words out of the seed map. For
an identity signed in with a bare key there are no words, and the honest
answer that screen would give is "this device holds no phrase for the
profile". So:
KeyRecoveryScreen branches on the active identity's kind. A mnemonic identity
keeps the phrase option. A NostrSecret identity gets a nostr secret key option
in its place, routing to NostrSecretRoute, with its own status line ("you said
you stored it") and a header that no longer promises coins. The backup flags
underneath are the same two per-identity preferences the phrase screen
writes; they already mean "this identity's secret is not backed up" for
whichever secret it is.
NostrSecretScreen is RecoveryPhraseScreen with the word grid replaced by the
nsec: hidden until revealed, revealed in monospace, selectable, with a copy
action -- nobody transcribes sixty-three characters by hand -- the same two
checkboxes, hidden again on leaving. The view model reads the key from
nostr-keys.dat at reveal time, by the identity's public key, not off the
active identity, so the screen's contract -- nothing secret held longer than
it is shown -- is the phrase screen's. The disclaimer is a new string: the
old one said "...and the funds in its wallet", and this identity has no
wallet to warn about.
Forget this key. An import needs an inverse, and this is the first real "sign
out" in the app; ActiveProfileScreen's button still routes to a pending
screen, and the general case stays there, because removing a seed is a wallet
question with funds behind it. Behind a dialog that names the npub and says
the profile stays on the relays, four effects in an order that matters: the
key out of nostr-keys.dat and the identity's preference files off the disk
(IdentityWriter.forgetNostrKey, which refuses a key that is not in the file
-- a wallet's nostr key lives in the seed); the metadata entry hidden, since
the metadata store has no delete and isHidden is what the selector filters
on; the account's unsigned rows deleted (forgetLocalAccount -- the kind 0
that made it a local account and anything queued that can now never be
signed, with the requests that hang off them cascading; published events
and the profile cache stay, as anyone else's would); and only then the
caller told, which re-lists identities and clears the active one so the
navigation observer sends a null identity to startup. A failure at the first
step leaves the rest untouched -- an identity the selector lists but nothing
can open is worse than one that is still there.
Tests. IdentityWriterJvmTest runs both bare-key writers against the jvm key
store and a real directory: a key written once and refused the second time
by public key, with its preferences file created; forgetting one key leaves
the other and takes its preferences with it; a key that is not in the file is
not forgotten. Its keys are fresh per test rather than fixed, because
DataStoreManager caches each id's preferences in a companion object for the
life of the process, and a fixed key was served the UserPrefs an earlier test
had created in an earlier temp directory -- worth knowing about that cache.
NostrSecretViewModelJvmTest records the four effects and asserts their order,
and that a key that could not be removed stops the sequence at one.
ForgetLocalAccountJvmTest, against Room, asserts the account and its cascaded
requests go while an unrelated account stays.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (901
tests) and :composeApp:m3Audit.
Replayed onto Mantra by docs/curated-to-mantra.md: KeyRecoveryScreen.kt: the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@e3cc23ae319b0ef6a7fb7a25c53a2f15d44cf720
2026-09-12 12:27:54 +02:00
<!-- Recovery for an identity with a key and no phrase, docs/nsec - sign - in.md, phase 6. -->
<string name= "copied_the_nsec" > Copied the nsec</string>
<string name= "copy_nsec" > Copy nsec</string>
<string name= "could_not_forget_the_key_please_try_again" > Could not forget the key. Please try again.</string>
<string name= "could_not_unlock_your_key_please_try" > Could not unlock your key. Please try again.</string>
<string name= "mantra_will_delete_the_nsec_for_s_from_this" > Mantra will delete the nsec for %1$s from this device. Anything not yet published is lost. The profile stays on the relays, and the key can be signed in again.</string>
<string name= "display_nostr_secret_key" > Display nostr secret key</string>
<string name= "forget" > Forget</string>
<string name= "forget_this_key" > Forget this key</string>
<string name= "forget_this_key_question" > Forget this key?</string>
<string name= "i_have_saved_my_nostr_secret_key_somewhere" > I have saved my nostr secret key somewhere safe.</string>
<string name= "i_understand_that_if_i_lose_this_phone_and_my_key" > I understand that if I lose this phone and my nostr secret key, I lose this profile.</string>
<string name= "keep_this_key_safe_do_not_share_it" > Keep this key safe.\nDo not share it.</string>
<string name= "key_forgotten" > Key forgotten</string>
<string name= "no_identity_is_open_on_this_device_so_there" > No profile is open on this device, so there is no key to show.</string>
<string name= "nostr_secret_key" > Nostr secret key</string>
<string name= "remove_the_key_from_this_device_the_profile" > Remove the key from this device. The profile stays on the relays.</string>
<string name= "store_the_nsec_that_signs_as_this_profile" > Store the nsec that signs as this profile. There is no phrase behind it: the key is the whole backup.</string>
<string name= "the_nsec_is_the_key_that_signs_as_you_this" > The nsec is the key that signs as you. This profile was signed in with it rather than made from a recovery phrase, so there is no phrase to write down and no wallet attached: the key is the whole of the backup.\n\nOnly you have it. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Store it somewhere safe that is not this phone. If you lose both the phone and the key, this profile is gone for good.</string>
<string name= "this_device_holds_no_key_for_the_profile" > This device holds no nostr secret key for the profile that is open. If it was made from a recovery phrase, the phrase is its backup.</string>
<string name= "unlocking_your_key" > Unlocking your key…</string>
<string name= "you_have_not_backed_up_your_nostr_secret_key" > You have not backed up your nostr secret key</string>
<string name= "these_are_your_keys_keep_them_safe_no_wallet" > These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone.</string>
<string name= "you_said_you_stored_it" > You said you stored it</string>
fix(ui): a back button on every pushed screen, and one spelling of it
Eighteen screens drew a back arrow in their app bar's leading slot and twenty-two
pushed screens did not. On Android the system back stood in for it and on iOS the
edge swipe, but the desktop target has neither, and a screen reached by
`navigate(...)` with no way to pop it is a dead end there: seven group forms, six
chat screens, five DKG screens, and four screens with no app bar at all.
**One widget, `NavigateBackButton`, rather than a nineteenth inline copy.** The
eighteen that had the button spelled it four ways -- `Icons.Filled.ArrowBack`, the
auto-mirrored one, `ArrowBackIosNew`, and a `"Back"` literal against a
`stringResource`. The widget decides twice: the icon is the auto-mirrored one,
because "back" points at the leading edge and the leading edge is on the right in an
RTL locale, which is what `Icons.Filled.ArrowBack` is deprecated for; and the
description is the catalogue's, because it is text. All forty-one sites use it now,
the eighteen converted mechanically with the imports they no longer need dropped.
**Each screen takes `onNavigateBack` and the host passes `popBackStack()`.** Hoisted
rather than read from a controller in the screen, so every one stays previewable
and testable without a nav host, which is how the twenty-two were fixed without a
nav host in a single test.
**Four screens had no bar to put it in, and got one.** `ChatRoomCreationScreen` is
"New chat", after the button that opens it. `CreateProfileScreen` is "Create
profile", and the two body headlines that repeated the name are gone, which is the
shape `SignInScreen` beside it on the landing page already has. `WriteNewNoteScreen`
is "New note" whether it is a reply, a quote or neither, one new string. The
"coming soon" placeholder's bar is titled with the name of what was tapped.
`AddMemberToChatRoomConfirmationScreen`'s bar had been commented out; it is back,
titled "Invite new member" since the body already names who and which room.
`NostrEventDetailScreen`'s repost and unknown-kind branches were the last two
placeholders without a bar.
**Two screens are reached two ways, and only the caller knows which.** Their
callback is nullable, and null draws no button. `ChatRoomMessagingScreen` is a
destination on a phone and the home screen's detail pane in an expanded window;
in the pane the room list is beside the transcript and a button that popped would
pop the home screen, so the pane passes null. `ImplementationPendingScreen` is
pushed from "learn more" and "edit profile" and is also where the navigation
observer lands with `popUpTo(0)` on an error; the host reads
`previousBackStackEntry`, remembered at first composition because the departing
screen reads it again after the stack has moved on. The DKG approval screens reuse
their existing `onDone`, which "Not now" already called -- the bar makes the same
leave reachable from the loading and error states, which had no other way off.
**Left alone on purpose.** The three top-level destinations have the navigation
bar, which phase 6 put there instead of app-bar icons. The onboarding and loading
screens cleared the stack to get where they are and have nothing under them.
`SearchScreen` and `SearchResultScreen` keep their `ArrowBackIosNew`: it is a search
bar's collapse control in a `leadingIcon` slot, not a navigation icon.
`NavigateBackButtonJvmTest` covers the two conditional screens by the description a
screen reader would announce: present and popping when pushed, absent in the pane
and at the root. The rule is recorded in CLAUDE.md beside the others a new screen has
to follow.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (938
tests, 4 new) and docs/scripts/m3-audit.sh --check, all budgets met.
Replayed onto Mantra by docs/curated-to-mantra.md: CuratedSuggestionListScreen.kt: taken as the original merge fedbe724 left it, this being the branch join; AcceptCuratedSuggestionScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts; BroadcastGroupSignedEventScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@0634487e120975156f5d3720e2d6ef9f82e38cf1
2026-09-12 13:55:42 +02:00
<!-- The bar on the note composer, which had no bar before it had a back button. -->
<string name= "new_note" > New note</string>
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue
Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is
unambiguous: "All text, including titles, headings, labels, menu items, navigation
components, app bars, and buttons should use sentence-style capitalization. ... Don't use
title case capitalization." The tree was title case throughout.
**100 occurrences across 60 distinct strings**, in two passes, and the second pass is the
interesting one.
The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or
`contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter",
"Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported
zero and the app still had "Invite a Friend" on its first screen.
Two holes. The pattern required every word after the first to be capitalised, so anything
with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact",
"Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the
next line was invisible. A whole-file scan allowing lowercase articles found 19 more
strings, 27 occurrences.
**Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank
Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and
titles of works, and title case is how those are written. The first audit swept them up
and reported 67 offenders where the real number was 41, which is the kind of number that
teaches a reader to ignore the tool.
Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is
Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is
written for whoever is reading logcat.
**Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in
with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you
sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became
"Lightning payment": M3's rule for a description is to name the purpose rather than the
picture, and "bolt" is the picture.
**The product has one name now, and it is Mantra.** The launcher label, the desktop window
title, the landing screen and the package all said Mantra; the home screen's app bar said
"Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed.
`UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire
to relay operators, so it is a network identity question rather than a content one, and a
comment at the call site says so.
**The two destructive actions now say what they do.** "Leave group" and "Delete group" are
`TextButton`s that fire immediately, with no confirmation step and nothing stating the
consequence. M3: "Tell users what will happen if they take an action and how they can undo
it."
Read out of the repository rather than guessed, because saying the wrong thing about a
destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and
posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and
nothing else. So: "Posts a line to the room saying you left, and lets you delete it from
this device afterwards", and "Removes the room from this device. The messages stay on the
relays and with the other members." The second matters most -- a button labelled "Delete
group" with no qualifier invites the belief that the messages are gone, which is the
opposite of true.
**1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix
wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts
-- and **nothing referenced any of it**. The tree's only two `stringResource` calls are
both commented out, and one of them names an `R.string`, which does not exist in a Compose
Multiplatform resource set at all. Keeping them made the file look like the app's
catalogue while the app's actual 332 strings sat in composables. It now holds `app_name`
and a note about what happens next.
A trap for the next person, recorded in the file: the compose resources plugin reports an
XML comment containing a double hyphen only as "XML file ... is not valid. Check the file
content." XML forbids `--` inside comments, and this commit hit it while writing that
note.
**The audit's check is now a script, for the reason the second pass exists.**
`docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by
name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three
ways and reported success anyway, which is worse than not checking.
**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug
apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals
themselves are the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
</resources>