Commit Graph

14 Commits

Author SHA1 Message Date
Kgothatso Ngako
5c893ab108 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
Kgothatso Ngako
6bd59e05fe 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
Kgothatso Ngako
b50b1762d4 feat: put a room's signing key first on its detail screen, and the signed event behind it
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
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
Kgothatso Ngako
113eda9f4d 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
Kgothatso Ngako
3382586501 Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
2026-09-08 09:31:16 +02:00
Kgothatso Ngako
64181bfcaa fix: say sign in is not available yet, instead of offering a flow alpha cannot finish
The sign in screen asked for an nsec or npub, walked through a confirmation
step and reported an error when the sign in failed. None of that can succeed
while the app is in alpha testing, so the screen is now the notice and nothing
else, centred in the window because the message is the only thing on it.

The screen no longer reads any state, so it takes no arguments and the
navigation host stops handing it the repository.

SignInToProfileViewModel, SignInToProfileUIState and SignInToProfileFormState
are left where they are. Nothing references them now, but they are the
implementation to restore when sign in ships, rather than something to write
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:28:11 +02:00
Kgothatso Ngako
5106f31332 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
Kgothatso Ngako
3f78eef1e8 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
Kgothatso Ngako
714354ae9a 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
Kgothatso Ngako
44bf2a01f0 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
Kgothatso Ngako
2117e22d48 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
Kgothatso Ngako
419504c982 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
Kgothatso Ngako
0304aca62a 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
Kgothatso Ngako
a10dc1a6f8 Add lightning-mobile support 2026-06-15 14:39:46 +02:00