31e6fc6425d2d9c310be63ca11f27163786887bd
710 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
31e6fc6425 |
fix(subgroups): ask whether the key state is signed by ceremony, not by room
Subgroups "2.0" and "2.1" on the connected devices had finished everything. A
ChillDKG COMPLETE for each, a birth certificate signed by the parent's quorum for
each, a key state signed by their own quorum for each -- all four events sitting in
the coordinator's database. The coordinator was offered no way to create the room.
`observeSignedGroupKeyState` resolved which key to ask about by looking the
ceremony up **from the room**:
getLatestSessionFor(chatRoomId, parentChatRoomId) // parent was null here
The view model calls it with no parent, so that reads "the ceremony in this room
that is *not* for a subgroup". A subgroup's ceremony room holds only the
subgroup's ceremony, so it matched nothing, derived no key, and reported "not
signed" over a state in the same database. The button is gated on that boolean, so
it never appeared.
This is the same mistake as `0b65d702` one layer down. That commit fixed the
*session* lookup by having the transcript name the ceremony; it left the key-state
and certificate lookups still re-deriving a ceremony from the room. A room does not
have one, and every lookup that assumes it does is wrong in a different way: by
room it finds none here, and by "newest in room" it would have found the wrong one
in the rooms from the previous report.
So both observers now take the ceremony they are about.
`observeSignedGroupKeyState(dkgSessionId)` derives the subject room from that
ceremony's own threshold key, and `observeBirthCertificate(dkgSessionId, parent)`
does the same. Neither can disagree with the ceremony the screen is showing,
because it is handed the same one.
In the view model they move into `observeForCeremony`, re-pointed when the
ceremony changes the way the message watcher already is -- previously the key-state
watcher was started once at init against a room, which is what let it drift from
the session on screen. `observeKeyState` keeps only the room-scoped watch of
signing sessions, which is genuinely a room question.
One test in `SignedGroupKeyStateTest`: a key state the group really signed is
reachable from the ceremony that produced it, and a ceremony with no key answers
null rather than throwing -- this flow runs from before a ceremony finishes.
397 common tests, 717 jvm tests, `m3Audit` meets every budget.
The two stuck subgroups will offer "create the subgroup" on the next build: their
key states are already signed and on file, and nothing about them has to be redone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0b65d7025b |
fix(subgroups): open the ceremony a transcript line is about, not the room's newest
Diagnosed off the four connected devices. Three subgroup ceremonies of "one (#admins)", all opened correctly by the coordinator, all sitting at COLLECTING_HOST_KEYS with 1 of 3 host keys in. Nothing was dropped: every participant device held the session, the ceremony room, its members and the "your approval is needed" line. What none of them could do was reach it. **A room does not have *a* ceremony, and every version of this screen has assumed it does.** A subgroup whose admins are the whole group runs in the room the group's own ceremony ran in -- that is the point of `1930d6aa`, and it is what the devices did. Ask that room for its ceremony and you get whichever was opened last. On the devices that was the group's own, already COMPLETE, in five of the six room/device pairs; the subgroup's request for a host key sat underneath it, unanswered, with the screen showing a finished ceremony and nothing to do. Both previous attempts were the same mistake: - unscoped "newest in room" -- picks the wrong ceremony whenever the other one is newer, which is what shipped and what the devices show; - scoped by purpose (`1930d6aa`) -- invisible to every member who did not open the subgroup, since only the coordinator's route carries a parent (`98626a9e`). Neither ordering can be right, because the question is wrong. **A line knows which ceremony it is about; the room does not.** So `ChatMessage` gains `dkgSessionId`, the pair to `frostSigningSessionId` and added for the same reason one-at-a-time stopped being true -- `docs/frost-batch-signing.md` reached this conclusion for signing sessions already, and the ceremony half was left on the clock because "a room runs one at a time". It doesn't any more. Every DKG line is stamped in `announce`, which all of them already funnel through. `DkgRitualRoute`, the three approval routes and their screens carry the id, the transcript's ritual notice passes the tapped line's, and `DkgRitualViewModel` observes that session when given one and the room's newest otherwise -- which is still the best a caller holding only a room can do, and is what the group's details entry passes. `ChatMessage.isAbout` uses it too, so `answeredRequests` stops crediting an answer given to one ceremony as an answer to the other. Rows written before the column read back null and fall back to the clock, which is correct for them: nothing that predates subgroups ran two ceremonies in one room. Schema 18, one nullable column, `AutoMigration(17, 18)`. One test in `RobustRoomKeyCeremonyTest`: with two ceremonies in one room, every line names one of them and a subgroup line resolves to the subgroup's ceremony. It deliberately does not assert which the room's "newest" is -- two ceremonies opened in the same second tie on `createdAt`, and the point is that nothing relies on that ordering any more. 397 common tests, 716 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
98626a9ef9 |
fix(subgroups): find a ceremony by the room, and read its purpose off the session
A subgroup's ceremony started and nobody else could see it. The session row was written, the transcript said so, and the shared-key screen showed "start a ceremony" as though nothing had happened. **Only the coordinator arrives knowing it is a subgroup.** They come from the picker, which puts `parentChatRoomId` on the route. Everybody else reaches that screen from the room -- the transcript's ritual notice, or the group's details -- and neither has a purpose to hand it, so both construct `DkgRitualRoute(chatRoomId)` with nothing. When the previous commit scoped the room's session lookup by purpose, `observeLatestSessionForChatRoom(room, null)` started meaning "the ceremony that is *not* for a subgroup", and a subgroup's session stopped being visible to every member but the one who opened it. **Scoping belongs where scoping is the question being asked**, which is "may I open another" -- `proposeRitual`'s guard and `refuseCeremonyRoom`. Those keep `getLatestSessionFor(room, parent)`. The screen asks a different question, "what is happening in this room", and there is only one honest answer to it: whatever ceremony is there. So the room's lookup goes back to being room-scoped and the purpose is read *off the session that turns up* rather than required in order to find it. That is also the better shape. A member who did not open the subgroup has no idea it is one until the proposal they were sent arrives, so the purpose could never have been an input on their side. **The certificate watchers move to follow the session.** They cannot start at init any more -- there is no parent to watch until one is known -- so they are cancelled and restarted from the session collector when the purpose changes, the way the message watcher already is. An ordinary ceremony passes null and gets nothing watched. **Three actions were reading the route as well**, which is the same bug one step on. `docs/subgroups.md` says only step 1 belongs to the coordinator: the key state and the room are open to any of the subgroup's admins, and they arrive by room. So `proposeBirthCertificate`, `proposeKeyState` and `createAdminGroup` now take the parent from the resolved state rather than the constructor -- otherwise a second admin finishing the flow would have created an ordinary `#admins` room with no parentage on it. Two tests in `RobustRoomKeyCeremonyTest`, both of which fail against the previous commit: a subgroup's ceremony is found by the room alone and carries the purpose with it, and a room holds its own ceremony and a subgroup's at once without either being mistaken for the other or a third being opened. 397 common tests, 715 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1930d6aaef |
fix(subgroups): a subgroup may be the whole group
"A subgroup cannot be the whole group. Leave at least one member out." That rule shipped in Phase 8 and it was wrong twice. **It refused something legitimate.** A subgroup is a logical division -- a group deciding that some of its work belongs to a differently-keyed room -- not a group carving out a smaller membership. Every member being in it is an ordinary case, and no guard here had any business deciding otherwise. **And it was a proxy, not a check.** The thing it stood in for is real: a ceremony room is derived from its admins, so a subgroup over everybody lands in the room the group's *own* ceremony was held in, and `proposeRitual` handing back that ceremony would quietly make the child the parent. But set size does not detect that. A parent whose membership has changed since its own ceremony derives a different room -- so the sizes can match with no collision, and differ with one. **Sharing the room was never the problem; sharing a ceremony was.** `DkgSession.parentChatRoomId` already told two ceremonies apart, so the fix is to scope the lookup by it rather than to forbid the selection. `DkgSessionDao.getLatestSessionFor(room, parent)` replaces `...ForChatRoom` at the three places that decide whether a ceremony already exists: `proposeRitual`'s one-at-a-time guard, `refuseCeremonyRoom`, and the two repository observers the ritual screen follows. A room may now hold the group's own ceremony and a subgroup's at once. Nothing below that lookup had to learn about the second one. A ceremony's messages, approvals and transcript are already keyed by session id; only the question "what is this room's current ceremony" was ever room-scoped, and that question was always really "for what purpose". One collision survives and it is degenerate: the same parent, over the same admins, twice. Those two have nothing left to distinguish them -- which is another way of saying they are one subgroup asked for twice, and that is what the message now says. `a subgroup that is the whole group is refused` becomes `a subgroup may be the whole group`, and two new cases pin the scoping: the group's own ceremony sitting in the derived room does not block a subgroup there, and the same parent asking twice over the same admins still does while a different admin set is untouched. `docs/subgroups.md` keeps the withdrawn rule struck through in the refusals table with a section saying why, rather than quietly deleting it -- the reasoning that led to it is the reasoning somebody would repeat. 397 common tests, 713 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ccaef5d36a |
fix(subgroups): read the admin list off the group's data, not off a flag no creator ever sets
"Only an admin of this group can make a subgroup", said to the admin who had just made the group. The guard was reading the wrong thing. **`Participant.adminAt` is written in exactly one place**: `MarmotInboundManager.processGroupMembershipChanges`, reached only from `NostrDao` on a `GroupEventResult.CommitProcessed` -- an *arriving* commit. The rows a creator makes come from `getOrCreateChatRoom` and `addMembers`, neither of which sets it. So on the device that created a room, every participant reads as a non-admin, including the creator, until some other member sends a commit it processes. A freshly made `#admins` room has had no commits at all, so a group whose epoch-0 context names three admins has none of them flagged on the one device certain to be one. **The guard now reads `MarmotGroupData.adminPubkeys`** off the room's own MLS state. That is the thing the column is a cache of: MIP-01 stamps it into the epoch-0 group context, every member gets it in their Welcome, and `processGroupMembershipChanges` reads exactly this when it writes the flag. Reading it directly cannot drift from what the group agreed and is right on both sides from the first moment. A room whose group data cannot be read refuses rather than falling back to the column. The compiler caught a second bug while this was being written: the parent's admin list was named `adminPublicKeys`, shadowing the parameter of the same name that holds the *subgroup's* picked admins -- and the ceremony room is derived from that parameter. It is now `parentAdminPublicKeys`, with a comment saying why the two must never be confused. **The column is caught up as well, because the UI still labels members with it.** `MarmotGroupCreation` stamps `adminAt` on the rows of the members the room was created with as admins -- the same list baked into `MarmotGroupData` a few lines above, so nothing new is asserted. Flags only: `processGroupMembershipChanges` also removes participants missing from the MLS tree, and running the whole reconciliation would soft-delete the rows of anyone `addMembers` could not reach, turning a partial invite into a partial membership. Widening a flag is safe; narrowing membership on a best-effort step is not. Swallowed on failure like `adopt` beside it: a missing flag costs a button not being offered, not correctness. **The old fixture was testing nothing.** It built the parent as a NIP-17 room with MLS state bolted on, and `ChatRoom.deriveChatRoomId` returns a 33-byte compressed key (66 hex) while `MarmotGroupData.nostrGroupId` takes 32 -- so `toExtension()` produced an extension `currentMarmotData()` read back as **null**, silently, taking the admin list with it. Every guard test was passing on that null. The parent is now built as what a real one is: the `#admins` room derived from the group's key, with `adminAt` deliberately left null on every row, which is the state the guard has to work in. Four new tests. Two in `SubgroupManagerJvmTest`: an admin is admitted with every `adminAt` still null, and a room with no MLS state has no admin list to consult. Two in a new `MarmotGroupCreationJvmTest`: the creator is flagged an admin of the room they just made, and the room can read its own group data back -- the second asserting `nostrGroupId` is the 64-hex derived id, since the wrong length there fails by returning null rather than by throwing. 397 common tests, 712 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0180425904 |
docs: record what the subgroups plan built, and the six places it chose differently
All nine phases are built, one commit each. The phases are kept as written -- they are the reasoning, and the code reads better against the argument it came from than against a summary of itself -- with a table of where the building disagreed with the plan. Six worth reading. `openCeremony` was never built, because a wrapper over two repository calls the picker already makes would be a third name for one act. `MarmotGroupCreation` is reached through the repository rather than called from a view model, because view models here talk to repositories and managers take the database. The guards' tests are in jvmTest rather than pure, because every refusal reads the database and a pure version would test less. Phase 4 added two tags rather than one, the second fixing a bug older than subgroups -- every robust group has been arriving nameless on every device but its creator's. `stateFrom` needed a third reader and a wrapper, because "tag present and unreadable" looks identical to "absent" through a parser, and "refused" has to be distinguishable from "none claimed". And Phase 8's two capability refusals short-circuited the tests already written, which is how it came out that the fixtures had never had a parent that could sign. Two the plan got right and worth keeping if this is ever rewritten: the key-package check moved to the picker on review, before a line was built, and it is the difference between a subgroup failing in a second and failing after three ceremonies; and the founding-roster rule has a test whose job is to fail the day somebody adds the comparison that looks obviously missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3ae49bd338 |
test(subgroups): the join between the pure rules and the columns they write
Phase 9 of docs/subgroups.md. Most of the plan's test work landed with the phase it guarded -- 13 pure cases in Phase 1, 10 more in Phase 3, 21 over a real database in Phases 2, 5 and 8. What was left is the one thing none of them can reach. `GroupKeyStateTest` settles Phase 3's rules purely and exhaustively, and `SubgroupDaoJvmTest` settles that schema 17 holds four columns. Neither can settle the **join**: that a parentage put on a proposal survives a real signing session, a real FROST aggregate and a real `record`, and lands on the *row* on every device rather than only in the event. That is a schema question wearing a protocol question's clothes, and it is exactly the sort of thing that breaks without failing -- `record` could drop both fields and every existing test would still pass. Three cases on the existing two-device harness in `SignedGroupKeyStateTest`, which already runs a whole session across two databases with nothing shared but what is ferried: - a quorum signing a subgroup's state puts the parent and the whole certificate on both devices, neither of which was sent a row -- each derived the event from its own items, re-ran `certifies` against the parent's id, and wrote the same two columns; - a real certificate really signed by the parent but naming another room is refused by `propose` before anything is published, and neither device ends up with a state; - a state signed with no parentage keeps both columns null, which is how every group made before subgroups reads and every top-level group made after. The parent is a second `KeyMaterial` and its signature is a real FROST aggregate assembled by hand. Standing up three more databases to get one would have tested the harness rather than the join. 397 common tests, 708 jvm tests, `m3Audit` meets every budget. All nine phases of docs/subgroups.md are built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b10c79f9ed |
feat(subgroups): the refusals that are about capability, not preference
Phase 8 of docs/subgroups.md. Four of the seven refusals landed with the code they guard; these are the three that did not, and two of them are about what this device *can* do rather than what the user should pick. **No share of the parent's key.** `FrostSigningManager.proposeSigningBatch` throws outright for a device with no share, so without this the button crashes rather than declines. It is checked first because it is the one refusal a user cannot fix by picking differently -- every other message says "pick differently" and this one cannot. **Not an admin of the parent.** A non-admin proposing the group's signature is a proposal the admins have to decline by hand, which is worse than not offering it. Read off `Participant.adminAt`, which is the epoch-0 admin list MIP-01 carries and the same reading every side of the group makes. Both are checked in `refuseCeremonyRoom` as well as at the button that hides itself on `canAddSubgroup`, because a screen not drawing something is not a guard -- and a resumed flow reaches the manager without passing that screen at all. **A child already certified.** One certificate per child: a second is a `d`-tag replacement of the first rather than a second subgroup, and spending a quorum's attention to restate something they have already signed is worse than doing nothing. This is a `check` at propose time rather than a picker refusal, since the child's id does not exist until the ceremony finishes. It deliberately does not try to stop the race. Two coordinators can each propose a certificate for the same child, neither able to see the other's session before it completes; `certificateFor` folds those together because both say the same true thing. This only stops the case somebody can actually see. The blank-name `require` gets a test of its own for the same reason the name exists at all: without it the parent's admins would be approving a hash. Six tests in `SubgroupManagerJvmTest`, and the fixture had to grow to carry them. `parentWith` now gives the parent a completed ceremony and marks the coordinator an admin, with both switchable, because a fixture missing either tests only the first refusal -- which is how the two new checks were found to short-circuit the existing cases the moment they were added. The plan called for a pure `SubgroupGuardsTest` in commonTest. It is not one: every refusal here reads the database -- a key-holding session, an admin flag, a ceremony in the derived room -- so the tests live in jvmTest beside the manager's others rather than being reshaped into something pure that would test less. 397 common tests, 705 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
3b00f9f893 |
refactor(subgroups): lift Marmot room creation out of the ritual view model
Phase 6 of docs/subgroups.md. `DkgRitualViewModel.createAdminGroup` was 120 lines of room creation living in a view model, and a subgroup needs all of it with four different values. It moves to `managers/MarmotGroupCreation`, unchanged in behaviour, and the view model shrinks by 154 lines to the four values and a `when` over the result. The rules that were already right stay right for the subgroup for free, which is the whole reason to move rather than to write a second one: **Every key package before anything exists.** The id is derived, so there is exactly one room per key at a path; a half-created one occupies that address permanently and there is no second id to retry with. Better to create nothing and name who is missing. (Phase 7 will check this at the picker instead, so a subgroup does not discover it after three ceremonies -- this stays as the backstop.) **`adminPubkeys` baked into the epoch-0 group context** rather than added by a later commit, so a member welcomed afterwards gets a populated group instead of chasing a bootstrap commit that predates their membership. That is why `MarmotGroupData` is built by hand rather than through `MarmotGroupData.bootstrap`, which hardcodes a single admin. **`adopt` before the members are added.** Filing the key state is local and certain; adding members is a relay round trip that can partly fail. The room comes into existence already knowing what it signs with, whatever happens next. **Derived ids make every step reachable twice**, so `Existing` is a success rather than a refusal -- a second tap or another member getting there first should join what exists rather than mint a rival group on one address. Four outcomes instead of four scattered early returns: `Created` with the members who could not be added, `Existing`, `BlockedOn` with the missing key packages, and `Failed`. The view model keeps the one thing that was genuinely its own -- turning missing public keys into names, since "no key package for 3 people" is not actionable and "Bob needs to publish a key package" is. **It is reached through `ChatRepository.createMarmotGroup`, not called directly.** View models in this app talk to repositories and managers take the database; the first cut had the view model reaching for `dkgRepository.database`, which does not exist on the interface and should not. The repository method is three lines of delegation and keeps the boundary where the rest of the app has it. `parentChatRoomId` is the one genuinely new parameter, written onto the room after `getOrCreateChatRoom` returns rather than passed into it -- that call is shared with every other way a room appears and none of them has a parent to hand it. Nine imports the extraction made dead are dropped from the view model. 397 common tests, 701 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
41853ab809 |
feat(subgroups): the manager, the two dispatch arms, and the list that reads signatures
Phase 5 of docs/subgroups.md. `SubgroupManager` orchestrates the four steps and reimplements none of them: step 1 is `ChillDkgRitualManager`, steps 2 and 3 are `FrostSigningManager` sessions, step 4 is Phase 6's. What lives here is the order, the guards, and the reading of what a parent has signed. **There is no table of subgroups, and there should not be.** The certificates *are* the record: the group's own signed statement, checkable without a lookup, and held by every device that followed the signing session rather than only by the signers -- `FrostSigningManager.complete` files a `GroupSignedEvent` everywhere. A table beside them would be a second copy that can disagree. So `subgroupsOf` reads the parent's kind-30329 rows, filters every one through `certifies`, groups by child and keeps the newest. That read has to include children this device has no room for. It is the normal position of a parent member who is not in the subgroup, and of everybody between the certificate being signed and the room being created -- so a list built from rooms would be empty exactly when it is most needed. `Subgroup` carries the room when there is one and null when there is not, and its `name` and `adminPublicKeys` are labelled in the type as the **founding** roster: what the parent approved, not who is in the room now. **Two certificates for one child is a normal outcome, not a conflict.** Two parent admins can press the button on the same admin set; the second gets the first's ceremony back but both may still propose, and both sessions can complete. `GroupSignedEvent` is keyed on the event id so the rows coexist, and because both say the same true thing about the same child, which wins does not matter. `certificateFor` takes the newest that verifies and the `d` tag makes them replacements rather than an accumulation. **`refuseCeremonyRoom` is where the collision from Phase 4 is caught.** A NIP-17 room's id is a pure function of its members, so one admin set gets one ceremony room forever. Four refusals, each phrased as what to do: fewer than three admins (the coordinator counts -- they hold a share whether or not anybody ticked them, so asking for three *others* would quietly build a group of four); somebody who is not in the parent; the whole group, which derives the parent's own ceremony room and would make the "child" this very group; and an admin set that already holds a non-failed ceremony, which is the same trap one step removed. **Two dispatch arms, both of the sort that fail silently if forgotten.** `ChatMessage.applyInnerEvent` gains a 30329 arm -- without it every parent member gets a raw-JSON chat bubble per subgroup, which is exactly the failure mode docs/member-chronicle.md reports for an old build meeting a new kind. It verifies rather than trusts, because it is reached both from a completed session, where the signature is checked, and from an arriving inner event, where a member could have sent a rumor of this kind, and it cannot tell which. Unlike the key-state arm it *does* write a line. A key state is standing state whose session already wrote the transcript; a subgroup being born is something that happened, and it happened on behalf of members of the parent room who are not in the child and will otherwise never learn it exists. `TYPE_SUBGROUP_CERTIFIED` joins the transcript's system-line dispatch and the chat-list preview, since a type missing from either renders as a bubble -- silently, and looking exactly like a member having said it. `ProposedEvent` gains a 30329 summary, or the parent's admins approve "Event of kind 30329" with the JSON underneath. It reads as the name and the member count, which are the two things a signer can actually weigh; the id is on the screen and their device has already checked it derives from the key the certificate names. **30329 is deliberately not chroniclable**, with a test saying so and why. Same shape as the key state: signed by the room, verifies perfectly, and standing rather than work. The argument for admitting it is better -- "P certified C" is a fixed historical fact -- and the cost of leaving it out is real, since a member added to a parent afterwards sees an empty subgroup list. It still needs an apply-order slot and a decision about whether a room's chronicle may carry an event its own key did not sign, which no chroniclable kind does. Thirteen tests in `SubgroupManagerJvmTest` over four real FROST groups, weighted to the negative cases the way the chronicle tests are: a certificate signed by another group, one nobody signed, and one whose id does not derive from the key it names are each filed in the parent's room and refused on read; `record` refuses a forgery and a certificate naming no subgroup; two certificates for one child collapse; two children stay two, newest first; and the four refusals each fire. 397 common tests, 701 jvm tests, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7bccf2438c |
feat(subgroups): say what a ceremony is for, and stop rooms arriving nameless
Phase 4 of docs/subgroups.md. Two tags on the ritual proposal, and only on the proposal. Neither is believed by anything. **`parent_group` says the ceremony is opening a subgroup.** `acceptProposal` files it on `DkgSession.parentChatRoomId` and the ritual screen will read it. Anybody can claim any parent and nothing is granted on the claim -- the participant set is still the p-tags, the threshold is still the threshold, and a proposal naming a group the receiver has never heard of opens exactly the ceremony it would have opened without the tag. The claim that decides anything is the birth certificate two steps later, which the parent's own quorum signs and which any device can check against the parent's room id alone. Carrying it is still worth it. Without it a member selected for a subgroup watches a shared key ceremony open in a room they did not ask for, with nothing saying what it is for. Being told is worth having even when the telling is not evidence, and the KDoc on the parser, the column and the `acceptProposal` call site all say so, because this is exactly the sort of field somebody later reaches for as if it meant something. **`subject` fixes a bug that predates subgroups.** `NostrDao.getOrCreateNip17ChatRoom` has always built the receiving side's room with `subject = decryptedGiftWrapPayload.parseSubject()`, and nothing on the ritual path ever wrote a subject tag. Standing up a NIP-17 room sends nothing to anybody, so the ritual proposal is routinely the first anyone hears of the room -- which meant the member who typed the name saw it and every other member got an untitled chat with a key ceremony already running in it. `SelectChatRoomTypeViewModel` has been passing a subject to `createNip17ChatRoom` that reached the creator's own device and stopped there. `broadcast` now reads `localChatRoom.chatRoom.subject` for the proposal, so every robust group created from here on names itself on the way out. Read and written through quartz's own `SubjectTag`, because it is the same tag and the same field it lands in. **Both are on the proposal alone.** Every later message belongs to a session the receiver already has both facts for, so repeating them would be bytes per round to restate something settled -- and would give a later message a say in what an opened ceremony is for. `includeParent` and `subject` are parameters of `broadcast` alongside the existing `includeThreshold`, which already works this way. `proposeRitual` and `DkgRepository.proposeRitual` take an optional `parentChatRoomId`; every existing call site is unchanged and gets null. Three tests in `RobustRoomKeyCeremonyTest`, which already stands up a real NIP-17 room and a real ceremony: the proposal carries the room's name and no later message repeats it; an ordinary ceremony claims no parent on any message; and a subgroup ceremony names its parent on the proposal alone, records it on the session, and changes nothing about the threshold or the participant count. Each walks every queued payload rather than checking the proposal alone, since the failure worth catching is a tag written on the wrong message. 396 common tests and 686 jvm tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
085e598ec5 |
feat(subgroups): a key state that names its parent, or is dropped for claiming one it cannot back
Phase 3 of docs/subgroups.md. `GroupKeyStateEvent` grows two optional tags and
`GroupKeyStateManager.stateFrom` grows the checks that make them mean something.
Every state signed before this reads exactly as it did: the tags are emitted only
when a parentage is passed, and the new checks fire only on a state carrying one.
```
["parent_group", <the parent room's id>]
["birth_certificate", <the parent's signed 30329, whole, as JSON>]
```
**Both or neither, and the type says so.** They arrive as one `SubgroupParentage`
rather than as two nullable parameters a caller could half-fill, because a parent
named with no certificate is a claim with the checkable part removed and a
certificate with no parent named beside it has nothing to be an index of. The
certificate is the claim; the parent tag is an index into it, since the
certificate already carries the same value in its own tag and as its author.
**Four checks, and a failure drops the whole state.** Both tags present and
readable; the certificate parses; `SubgroupBirthCertificateEvent.certifies` says
the named parent signed it for *this* room; and the certificate's `subgroup_key`
is the state's own threshold key. The last is belt and braces -- the room's id
already derives from that key and the certificate's id already derives from the
key it names -- and is stated anyway because the two facts live in different files
and a change to either should have to notice this one.
Keeping a failed claim as a *parentless* state was the alternative and is worse.
It is not a state with one field wrong; it is a device asserting a relationship
the parent never agreed to, and filing it would record a group as top-level here
and as a subgroup on every device that could check the certificate.
**`claimsParentage` exists because absent and unreadable are not the same.** A
`birth_certificate` tag carrying `{not json` reads as absent through
`parseBirthCertificate`, so a state claiming a parent in a form nothing can check
would otherwise be filed as an ordinary top-level group. It looks at the tag names
alone, which is the only reading that can tell the two apart.
**The three outcomes needed a wrapper.** `parentageOf` returns `Parentage?` where
null means refused and a present null value means none claimed -- a bare nullable
carries two of the three, and flattening "cannot prove it" into "did not claim
one" is exactly the bug the paragraph above describes.
`propose` takes the same optional parentage and re-runs `certifies` before
opening the session. Every device that receives the state runs that check and
drops it when it fails, so proposing one this device would not believe spends a
quorum's attention on a statement nobody will keep.
**The certificate travels whole rather than as its signature**, argued in
`SubgroupBirthCertificateTag`: a signature plus a rule for rebuilding the event it
covers breaks silently the first time the certificate's shape changes, since a
rebuild differing by one byte hashes to an id whose signature fails and is
indistinguishable from a forgery. Every state already signed would stop being
believed at once, for a reason nothing logs.
Ten tests appended to `GroupKeyStateTest`, using the existing `signedByRoom`
helper so the certificates are real FROST signatures by a real second group: the
happy path keeps both fields; no claim keeps both null; each half alone is
dropped; an unparseable certificate is dropped; a real certificate for another
child stapled on is dropped; one signed by this room's own group rather than the
named parent is dropped; the index disagreeing with the claim is dropped; a
certificate naming another key is dropped; and the tags round-trip, with a
top-level state carrying neither. 396 common tests and 683 jvm tests pass.
The end-to-end through a real session and two databases lands with Phase 9, once
`SubgroupManager` exists to drive one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f6217ce6ea |
feat(subgroups): schema 17 -- four nullable columns, and not one of them a foreign key
Phase 2 of docs/subgroups.md. Somewhere to put a parent, now that Phase 1 can prove one. | table | column | filled from | trusted? | |---|---|---|---| | GroupKeyState | parentChatRoomId | the state's parent tag | yes -- the certificate was checked | | GroupKeyState | birthCertificateJson | the state's certificate tag | yes -- same | | ChatRoom | parentChatRoomId | the verified key state | yes | | DkgSession | parentChatRoomId | a tag on a ceremony proposal | **no** -- a screen's title | The trust column is the point of the table and is written into the KDoc of each one. Three of these are written only after a signature has been checked; the fourth is an unverified claim off a wire message, and a column that mixed the two would be a column no reader could act on. Nothing may be granted on the strength of `DkgSession.parentChatRoomId` that would not be granted without it. **None of the four is a foreign key, and that is the change most likely to be "fixed" by somebody later.** `GroupKeyState`, `DkgSession` and `GroupSignedEvent` all declare `ForeignKey(onDelete = CASCADE)` onto ChatRoom, so pointing a parent column at ChatRoom the same way is the obvious next move. It would mean deleting a parent room deletes every subgroup row beneath it -- and then, by their own cascades, each subgroup's messages, participants, key state, signing sessions and signed events. A user tidying away a group they had left would silently destroy a group they are still in. RESTRICT is no better: it would make a parent undeletable while any child row exists, which is a foreign key deciding a product question. And neither would work anyway, because a parent pointer routinely names a room this device does not have at all -- a member of a subgroup who was never in its parent holds the id off a certificate and nothing else. A dangling reference is the normal, expected state here, and readers resolve it with a lookup allowed to return null. **The certificate is stored whole, as JSON, rather than as its signature.** A signature plus a rule for rebuilding the event it covers is a rule that breaks silently the first time the certificate's shape changes: a rebuild differing by one byte hashes to an id whose signature fails, and is indistinguishable from a forgery. A few hundred bytes inside an encryption removes the class. The parent column beside it is an index into that event, never a second source of truth -- the two are written together or not at all. Four DAO reads, each with the limits of what it answers written down. `GroupKeyStateDao.getByParentChatRoomId`/`observe` list the children whose state this device holds, which is *verified* but not complete -- a certified child whose room was never created here leaves no state at all. `ChatRoomDao.observeByParentChatRoomId` lists the ones there is something to open, excluding soft-deleted rooms so a room the user cleared away does not reappear because its parent lists it. `DkgSessionDao.getByParentChatRoomId` is how a member gets back into a subgroup flow they closed the app during, since before the certificate is signed the ceremony is the only thing on the device that knows the flow was started. `AutoMigration(16, 17)`: nullable additions are a shape Room migrates itself, and 17.json exports with no new foreign key on any of the three tables. Nine tests in `SubgroupDaoJvmTest`, all on properties the compiler cannot see: a state and a room may each name a parent this device holds no room for; deleting a parent leaves its child, its child's key state and its child's lineage standing; the parent lists its children newest-first and filters on *which* parent rather than on having one; a soft-deleted subgroup drops out; and a ceremony round-trips the parent it was opened for. 386 common tests and 673 jvm tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
51d6a8841a |
feat(subgroups): a birth certificate, and the six questions that make one mean anything
Phase 1 of docs/subgroups.md. A group can now say, with a quorum, that another group is its child -- and any device holding the event can check it without a database, a lookup or a key it has to be told. This is the whole of what a subgroup relationship is. A child gets its own ChillDKG key, its own room, its own quorum and its own admins; nothing is inherited and nothing is delegated. What the certificate carries is one checkable claim: the group holding key P said, with a quorum, that the room C is its child. **Kind 30329**, past `GroupKeyStateEvent` (30326) and the chronicle pair (30327-30328), in the same private inner-event space. Like them it says something *about* a room rather than carrying the room's work, and like them it only ever exists inside an encryption a relay cannot open -- so the addressable semantics of the 3xxxx range never fire, and the `d` tag is this app's own newest-wins rule rather than a relay's. **Content is the child's room id, exactly as specified; the tags are what make it checkable.** Taken literally a certificate is 32 opaque bytes, and a parent admin would be asked to put the group's signature to a number they cannot check, produced by a ceremony most of them were not in, on behalf of people they have only the coordinator's word about. So the tags carry the child's threshold key, the derivation path, its founding admins and its name. The signature covers all of it, since an event id hashes over its tags, so nothing is added to the *claim* by putting it there -- only to what a signer can see before agreeing. The one that earns its place is `subgroup_key`: with it a signer's device can check `marmotGroupId(key, path) == content` for itself, which is the difference between approving a hash and approving a group. A coordinator who lies about who is in the child is then lying in a field the parent's signature covers. **`certifies` is six questions and no trust.** It is a certificate at all; it is about this child in both the content and the `d` tag, which have to agree; it names this parent; the child's id rederives from the key and path it carries; the parent room signed it; and all of it inside a `runCatching`, because every input is off the wire and a key that is not a point, a signature that is not 64 bytes and hex that is not hex all mean the same thing here. The fourth is the half that does not care who is speaking -- a certificate cannot be pointed at a room the key it names did not make -- and the fifth is the half that does. The fifth is `GroupKeyStateEvent.isSignedByRoom` used verbatim rather than reimplemented. It already asks "did *this room* sign this", and a room id is a public key here, which is the economy docs/member-chronicle.md is built on. Hex is compared case-insensitively as that check compares the author, since a certificate differing in case from what was signed fails the signature anyway -- so all this decides is whether a caller holding the same id in another case gets a silent drop. **What `certifies` deliberately does not check, and a test that fails if anybody adds it.** The name and the `p` tags are the *founding* roster. A certificate is signed once; members join and leave and rooms get renamed afterwards, and none of that reaches a signature already made. Comparing either against a room's current state would start rejecting valid certificates the first time somebody joined a subgroup, and the rejection would look exactly like a forgery rather than like a rule. `a certificate still verifies once the subgroup has been renamed and re-staffed` is there to make that failure loud instead of subtle. `parseAdminPublicKeys` uses `PTag.parseKey` rather than `PTag.parse`: the relay hint a full PTag carries is not part of what the parent agreed to, and a hint that failed to normalise would drop an admin from the roster rather than the hint from the admin. Two tag classes in the `FrostDerivationPathTag` shape. `SubgroupParentTag` checks nothing beyond having a value, because what makes a parent claim mean anything is the signature and a shape check in front of it would only decide which of two rejections a bad value gets. `SubgroupKeyTag` borrows its shape check from `GroupKeyStateEvent.parseThresholdPublicKey` rather than restating it, so two readers of one value cannot disagree about what a threshold key is. 13 tests, all pure: three independent groups from `Frost.trustedDealerKeygen`, a real FROST aggregate through the same nonce/signer-set/partial/aggregate shape `FrostSigningManager.advance` runs, and the author and the signer pulled apart so that both halves of question five are exercised separately. 386 common tests and 664 jvm tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6fb0af1147 |
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
A review pass over the plan committed in
|
||
|
|
c1ce262f3e |
docs: plan subgroups, and phase the four ceremonies a group needs to make one
A group can make another group, and the child can prove where it came from. This
is the plan for that, in nine phases, written against the code at
|
||
|
|
b50b1762d4 |
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> |
||
|
|
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> |
||
|
|
3382586501 | Merge branch 'mantra' into claude/key-recovery-functionality-00e269 | ||
|
|
e05e4fd051 | Merge branch 'mantra' into claude/sign-in-alpha-message-fd21ca | ||
|
|
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> |
||
|
|
bc83762274 | Merge branch 'mantra' into claude/key-recovery-functionality-00e269 | ||
|
|
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> |
||
|
|
c7b2dd25d9 |
feat: give the profile's key entry a recovery screen instead of a dead end
"Profile Keys" was an ImplementationPendingRoute: a key icon that led nowhere. It is now "Key Recovery", and it opens the recovery hub the Machankura app already has -- recovery phrase, cloud backup, emergency kit, and YOLO -- because a mantra profile *is* its seed. The npub that signs and the wallet that holds coins both come off one seed that never leaves the device, so a phone lost before a backup takes the account with it, and there is no server to ask for it back. The recovery phrase screen is the part that does the work: it decrypts the seed file through the existing loadAndDecryptSeed expect/actual, matches the active wallet's id against it, and shows that wallet's 12 words numbered in two columns behind an explicit reveal. The two backup confirmations are stored in that wallet's InternalPrefs, so they survive a re-install on the same seed, and the not-backed-up warning they clear is also what the hub reads for its status line. Cloud backup and the emergency kit route to the app's "coming soon" screen; they are placeholders in Machankura too. The read runs on Dispatchers.IO -- it is keystore-backed and blocks -- and the state it writes is a MutableStateFlow rather than a mutableStateOf, because a ViewModel built during composition silently loses an off-main-thread write made inside that first composition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b57fcb84c | Merge branch 'mantra' into claude/amazing-roentgen-929dc5 | ||
|
|
b942e74449 | Update android build | ||
|
|
a56295b0e2 |
docs: record what phase 8 built, and what is left for a person across all nine
The plan's last phase becomes a record, and the document gains a closing status: every count the audit was written to move, from the state in "Where this app stands" to what `m3-audit.sh` reports today, and a gathered list of what a person still has to look at — the eight screens with competing filled buttons, the two list-detail families the pane work did not reach, the container transform, desktop keyboard traversal, and the avatar picker's selected state. **The audit caught the previous commit.** `ThemeGallery` added eight string literals in composables, taking the count 39 -> 47, which is exactly the drift the budget exists to notice. They are sample text — the words are chosen to be words, so that colour pairings can be looked at — and putting them in the catalogue would add eight entries no screen shows and a translator would have to be told to ignore. So the audit grows a third exemption marker beside `m3-color-exempt` and `m3-spacing-exempt`: `m3-string-exempt`, per file rather than per line, because the exemption is a property of what the file is for and eight markers down one gallery would say less than one at the top of it. Back to 39, and the report now says how many files are exempt so the mechanism cannot be used quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5f0afdf34d |
feat: render every preview under the five conditions a screen has to survive
Phase 8, item three. The tree had 51 previews and every one of them rendered one thing: a light theme, at whatever width the preview pane happened to be, at 100% text. That is the only condition under which this app has never had a defect. `@ConformancePreviews` replaces the bare `@Preview` at all 53 sites -- the two outside `ui/` included -- and renders each under five: light, dark, 200% text, compact 400dp, expanded 1000dp. `@Preview` is `@Repeatable`, so this is one annotation rather than five copied onto every preview and drifting apart. Each of the four new conditions is where a defect in this app has actually been: a colour that only fails in dark, a fixed-height container that clips at 200%, a layout that stretches because nothing held it, a row that reflows badly at phone width. It also gives phase 6 the check it could not make. "Every screen renders correctly at 400dp, 700dp, 1000dp, 1400dp and 1800dp" was verified structurally -- the measure applied at every root and asserted at those widths -- but never looked at per screen. Two of those widths are now one click away on every screen in the app. **High contrast is deliberately not in the annotation**, and the argument is worth stating because the omission looks like a gap. Contrast is a property of the *scheme*, not of a screen: the app declares six, `ColorSchemeContrastTest` measures every pair in all six, and a screen right in the default scheme is right in the high-contrast one by construction. Per-screen high-contrast previews would be 51 more renders of something already proved -- and there is no `@Preview` parameter for it in any case, since it needs `TorchTheme(contrast = …)` in the body. `ThemeGallery` covers them instead, once, over components rather than screens: all six schemes side by side, with body copy on surface, a card holding a list item -- the arrangement that rendered a headline at 1.00:1 before phase 3 -- and the three button emphases. `dynamicColor = false` on purpose, or an android 12+ preview paints all six columns from the wallpaper and the gallery shows nothing. It is the only place the medium and high contrast schemes can be seen at all: in the app they are reachable only through a platform setting, and on android only with dynamic colour off. 99 lines changed across 50 files, all of them one annotation and its import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9946f98ca8 |
docs: write the UI conventions down where they are needed
Phase 8, item four. This repository had no `CLAUDE.md` at all, so eight phases of decisions lived only in `docs/material-design-conformance.md` -- a 980-line account that is the right place to explain *why* and the wrong place to look while writing a screen. Seven rules, each with the shape to copy, the shape not to, and the budget the audit holds it to: spacing from the scale, colour from a role, text from the catalogue in sentence case, 48dp targets with decided icon descriptions, four screen states with a transition between them, layout that reads the window rather than the composable, motion from the scheme. Where a rule has a trap that has already caught somebody, the trap is named rather than the rule restated: `.copy(alpha = …)` on a content role is how nine contrast failures got in; Compose Resources unescapes `\n` but not `\'`; `AnimatedContent` is a layout node and takes a `when`'s branches out of `ColumnScope`; `MotionSchemeKeyTokens` is internal and cannot be reached from an app; `rememberNotifier` and `stringResource` are composable and an `onClick` is not. Every API named in it was checked to exist, and the notifier example was corrected after the first draft got its signature wrong -- it takes the caller's scope, so that a message shown as a screen navigates away is not cancelled with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9f09f48133 |
build: make the conformance audit part of check, and gate the branch on it
Phase 8, the first two items. The audit has existed since phase 0 and has been run by hand at the end of every phase since, which is exactly the arrangement it was written to end: a budget nobody checks at the moment the number moves is a number that drifts. **`:composeApp:m3Audit`, wired into `check`.** It shells out to `docs/scripts/m3-audit.sh --check` and fails the build when a budget is exceeded or a floor is undercut. Verified to bite: adding one `Color(0xFFAABBCC)` to `LoadingScreen.kt` reports `hardcoded Color outside theme/ 1 over budget 0` and takes the build down with it. The task declares the script and the ui source tree as inputs and a marker file as its output, so it is up-to-date-able rather than re-running on every `check`. On a machine with no bash it warns and skips instead of failing, because a build that dies for a reason unrelated to the change under it teaches people to pass `-x`. **A Gitea Actions workflow**, since the remote is a Gitea 1.25 instance. Two jobs, deliberately: - `budgets` is grep over the source tree -- no gradle, no android SDK, no submodules, no network. This job is the reason the audit is a shell script rather than a gradle plugin, and it should stay runnable on a bare container. - `tests` needs a compiler and therefore the whole composite chain: four levels of submodule and a cross-compile of secp256k1's C sources, so a cold run is minutes rather than seconds. Split out so a runner can be pointed at `budgets` alone where that is all the capacity there is. Its two non-obvious steps carry the reasons at the site -- `submodules: recursive` or configuration fails with `Project with path ':library' not found`, and the android SDK is needed even for a jvm-only test run because `:secp256k1-kmp:jni:android` is in the graph. **The workflow is unverified**, and that is worth saying plainly: this repository has had no CI of any kind, so there is no runner registered to try it against. The syntax is valid and the commands are the ones used by hand throughout this work. The gradle task is the half that is proven, and it is the half that runs on every developer machine regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b6aa2111ac |
docs: record what phase 7 built, including the API the plan named that does not exist
Phase 7 becomes a record. Three things in it are corrections to the plan rather than notes on it, and all three are the kind that only surface once somebody tries: - `MotionSchemeKeyTokens`, which the plan says every spec should come from, is `internal` to material3 and not addressable from an app. `MaterialTheme.motionScheme` is the public surface and gives the same six specs. - "every state change in the app is a hard cut" was true of screen states and not of navigation, whose default is a 700ms fade in navigation-compose's internals. Still worth replacing -- three times M3's duration, and a literal in a dependency -- but for a different reason than the one written down. - Android has no reduce-motion setting. It has "Remove animations", which zeroes the animation duration scales, and Compose ignores those scales entirely. Also what was deliberately left: the container transform between a list item and its detail screen. It is `SharedTransitionLayout` work, and above the expanded breakpoint the detail is already beside the list, so there is no container to transform -- doing it before the pane split settles means writing it twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
410ece2df9 |
feat: fade between a screen's states instead of cutting between them
Phase 7, the second half. Every screen in this app is a `when` over a UI state -- loading, error, empty, loaded -- and every one of those changes was an unannounced cut: the spinner is there in one frame and the content is there in the next, with nothing saying they are the same screen answering the same question. `ScreenStateTransition` is M3's fade-through, which is the transition for content that replaces other content without being spatially related to it: the outgoing state fades out, the incoming one fades in and grows the last 8% into place. `SizeTransform(clip = false)`, so a tall loaded state does not stretch a short spinner on its way in. Specs from the theme's `MotionScheme`, effects for the fade and spatial for the scale. **The content key is the state's class, not the state.** This is the half that is easy to get wrong and impossible to see: keyed on the value, a screen re-runs the whole fade every time its loaded data changes -- a message arriving, a list growing by one -- so the screen flickers whenever anything happens, and every screenshot of it looks perfect. Keyed on the class, the animation runs when the state does and the data flows through untouched. There is a test for exactly that, and it is the more useful of the two. **Applied to 20 screens, and not to 15 others.** `AnimatedContent` is a layout node, so it can only wrap a `when` that is a composable's whole body. Where the `when` sits inside a `Column` whose branches use `Modifier.weight` -- the sign-in and create-profile flows, the frost signing and proposal screens, the two feed detail widgets, the four render helpers still on view models -- wrapping it would take those branches out of `ColumnScope`. The rule is mechanical, the reason is recorded once in `ScreenState.kt` rather than at each site, and the screens it excludes are named here rather than silently skipped. Reduced motion keeps the crossfade and drops the scale, which is the same position the navigation transitions take: what WCAG 2.3.3 and M3 ask to remove is movement, not the signal that something changed. **Most of this diff is indentation** -- 3,699 lines of it against 157 lines of substance, which is 21 screens gaining a wrapper and one helper being written. `git diff -w` shows the second number. **Verified by holding the clock still and looking at one frame**, which is the only frame that can tell a crossfade from a cut: during a transition both states are composed, and during a cut only ever one is. 639 jvm tests green; android and desktop compile. The audit's motion count goes 11 -> 13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
61793e2779 |
feat: give navigation its transitions from the motion scheme, and honour reduced motion
Phase 7, the first half. All 43 routes took navigation-compose's default, which turns out not to be the hard cut the plan expected: on android and desktop it is `fadeIn(tween(700))` / `fadeOut(tween(700))`, written into the library's own internals. Both halves of that are worth changing. 700ms is roughly three times M3's duration for a full-screen change, and a literal inside a dependency is not a decision this app made -- phase 1 wired a `MotionScheme` into the theme precisely so that there would be one place to make it. **The plan named an API that an app cannot reach.** It says every spec should come from `MotionSchemeKeyTokens`; that enum is `internal` to material3, so the tokens are not addressable by name from outside. `MaterialTheme.motionScheme` is the public surface and offers the same six specs. Two private helpers name which of them this app uses for what -- `defaultSpatialSpec` for the slide, `defaultEffectsSpec` for the fade -- which is the distinction the scheme draws: spatial motion is springy because it moves something, effects motion is not because a fading colour that overshoots looks like a fault. **The shape is M3's shared axis.** The arriving screen slides in from the trailing edge while the leaving one slides out toward the leading edge, both fading; going back mirrors it, so the direction of travel is legible rather than a dissolve that looks the same either way. `slideIntoContainer` is layout-direction aware, so an RTL locale gets the mirror for free. **Reduced motion, on the three platforms, in the shape phase 1 established.** `platformReducedMotion()` is an expect/actual beside `platformThemeContrast()`, observed rather than read once, because somebody who turns it on because motion makes them ill should not have to restart the app. Android has no "reduce motion" switch -- it has **Remove animations**, which sets the animation duration scales to zero. The platform applies that scale to `ValueAnimator` and **not to Compose**, which runs on its own clock and ignores it entirely, so an app that draws its own transitions has to read the setting itself. A `ContentObserver` on `ANIMATOR_DURATION_SCALE` catches the change without a restart. iOS is the one platform where it is a single documented call, `UIAccessibilityIsReduceMotionEnabled`, with the same notification shape as the darker-system-colours one already observed there. Desktop answers `false`, and says at the site why that is honest rather than a stub: Windows, macos and the freedesktop desktops each have the setting and none of the three reaches AWT. That is the same wall `platformThemeContrast` hits on linux and macos, and the same eventual answer -- a preference with the platform as its default. Reduced motion does not mean *no* transition. The screen still fades; what goes is the movement, which is what M3 and WCAG 2.3.3 are both about. **Two tests, and the first one found a design flaw in the second.** The claim "this transition slides and that one does not" cannot be asserted on the values -- `EnterTransition` has no public shape to inspect -- so it is measured: hold the clock, navigate, advance a third of the way, and read where the arriving screen is. Sliding, it is 54dp from home; reduced, it is already there. The reduced case read 54dp at first, because the test provided `LocalReducedMotion` *around* `TorchTheme` and the theme overwrote it. The fix is not in the test: `reducedMotion` is now a `TorchTheme` parameter defaulted to the platform, exactly as `contrast` is, because a value nothing can override is a value nothing can test -- and because the desktop actual is a hardcoded `false` that a settings screen will eventually need to override anyway. 637 jvm tests green; android and desktop compile. The audit's motion count goes 2 -> 11 and navigation transitions 0 -> 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd40eeda76 |
fix: break the transcript's same-second tie on write order, so a burst reads forwards
`ChatMessageDao`'s two transcript queries ordered `createdAt DESC` and nothing
else. `MantraConverters` stores an `Instant` as epoch seconds, so lines written
inside one second tie -- a ceremony puts a dozen into a room faster than that,
and a request with the answer it triggers routinely lands inside one -- and with
no second key SQLite hands them back in scan order, which is rowid *ascending*.
Under a descending query drawn bottom-up by the feed's `reverseLayout`, that
draws a same-second burst backwards. Three lines written in one second, read
back through the old query:
expected:<[third, second, first]> but was:<[first, second, third]>
**The list and the room it opens already disagreed.** `ChatRoomDao` picks each
room's preview with `ORDER BY createdAt DESC, id DESC`, and
|
||
|
|
16775bf6c7 |
docs: record what phase 6 built, and give the audit a floor to defend it
The plan's phase 6 becomes a record rather than a proposal, in the shape the earlier phases took: what was built, what was decided and why, what a person still has to look at. Two decisions in it were the product owner's rather than the code's -- promoting search and profile to navigation destinations, and doing chat alone rather than all three list-detail families -- and both are named as such with the date. **The audit learns two things.** It counted `NavigationBar(`, `NavigationRail(` and friends, and reported **zero** for an app that had just grown a navigation bar: `NavigationSuiteScaffold` is what chooses between them per breakpoint, and the concrete component never appears in the source. It now counts the scaffold and its items. And it grew a `floor()` beside `report()`. Every other budget in the file is a ceiling that ratchets down as a phase lands, which is the right shape for literals, hardcoded colours and untriaged nulls -- things a careless edit *adds*. The adaptive work is the opposite: a screen that stops reading the breakpoint still compiles and still renders, and the count goes down. So `--check` now also fails when the adaptive API count drops below 12 or the navigation component count below 2. **Two `contentDescription = null` that the audit caught in this phase's own work** -- the navigation item's icon and the new-chat button's -- now say `Decorative`. Same null, and the same convention phase 3 established: recording that somebody looked is the whole point, and a budget of zero only holds if new code obeys it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
a53a9c3e11 |
feat: open the desktop window at a width the layouts are now written for
Phase 6, step 6. The window opened at 480x900 under a comment that said why: *"The layouts have only ever been exercised at phone widths. This is a starting size that does not immediately misrepresent them, not a considered desktop layout."* That was honest, and it has stopped being true. 1100dp is inside the expanded breakpoint (840-1199), which is the narrowest window M3 recommends two panes in and so the smallest opening size at which a desktop user sees a desktop layout rather than a phone one stretched sideways. The content does not stretch to fill it: screens are held to a readable measure and centred, so the extra width becomes margin. Also a minimum size, which the window never had. Compose Desktop's `WindowState` carries no minimum, so the window could be dragged narrower than anything in the app was written for; 400x600 is the narrowest of the five widths this phase is meant to be checked at, and the compact breakpoint's own floor is a phone rather than nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e95ece9027 |
fix: give form helper text and the review list the leading edge of what they describe
Phase 6, step 5, second half: the plan asked to revisit the 91 `TextAlign.Center`
uses, on the grounds that start alignment is what gives the rulers something to
align to. Revisited, and 84 of them are right.
Centring is correct for a block that is the only thing on the screen, because
there is nothing for it to align to: an empty state, a loading or error message,
one of the six onboarding status screens, a "coming soon" placeholder, the
landing screen's hero, a dialog's title. Converting those would have been a
restyle wearing a conformance argument.
Seven were wrong, and they share one shape -- text sitting in a column *beside a
full-width element*, so there was a leading edge and it was being ignored:
- the two helper lines under `CreateProfileScreen`'s name and bio fields, and
the two under `ChatRoomCreationScreen`'s. Each `TextField` is
`fillMaxWidth()`, and its label, placeholder, leading icon and supporting
text all begin at the same edge; the sentence explaining the field floated
centred at whatever width it happened to be;
- `SelectChatRoomTypeScreen`'s "this decides who can change the group later",
which sits directly above three full-width cards;
- `CreateProfileScreen`'s confirmation list, where "Name" and the name below it
were each centred at their own width, so the label and the value it labels
started in different places. Five texts there now share one edge.
The parent columns are still `Alignment.CenterHorizontally`, which is why each of
these needed `fillMaxWidth()` and not merely the removal of `textAlign`: a `Text`
without a width in a centred column is centred as a box, so dropping the text
alignment alone would have changed nothing visible.
Nothing else in the sweep moves. The remaining 84 are listed above by category
rather than site because the category is the reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
05e80bf099 |
feat: hold every screen's content to a readable line, and centre it in the window
Phase 6, step 5, first half. Every one of the 40 screens rendered a single column that filled whatever width it was given, so on a 1800dp desktop window a paragraph became a 1800dp line -- long enough that the eye loses the start of the next one -- and a six-character text field stretched to 1700dp. M3: *"across all breakpoints, adjust margins and type styles to keep text between 40–60 characters per line."* **The measure is derived, not written down.** `readableContentWidth()` is `bodyLarge`'s font size converted through the current density, times half an em per character, times sixty: 480dp at the default text size. Writing `480.dp` instead would be the same number today and wrong for anybody who has turned text size up -- at 200% the same column holds thirty characters, silently, because the text still fits. Deriving it means the column widens with the type and keeps its sixty. `AverageCharacterAdvance` is the one estimate in it, named and documented, because a proportional face has no character width and half an em is the standard figure for mixed-case Latin prose. Only the ceiling is enforced. The floor needs nothing: a 400dp compact window less its two 16dp margins holds about 46 characters, which is inside the range, and no cap can add characters to a window that has none. There is a test for exactly that, so the claim is checked rather than asserted in a comment. **The column is centred; the text is not.** Those are opposite things and it is worth being explicit, because "centre it" is how the second one gets done by accident. A centred column still has one straight leading edge for every row, avatar and icon to align to, which is what the grids-and-spacing page asks for. Centred text has none. The 91 `TextAlign.Center` uses are a separate question and a separate commit. **Applied at 49 sites in one pass**, at the point every screen consumes its `Scaffold`'s padding -- the one place in each file that is reliably the top of the content. Below 480dp it is not a cap, an inset or a centring; it is nothing, so no phone layout moves. **Verified by measuring a real composition, not by reading the code.** `readableContent()` is `fillMaxWidth` then `wrapContentWidth` then `widthIn`, and every permutation of those three compiles and renders something that looks right in a phone-width preview. This needed `compose.desktop.uiTestJUnit4` in `jvmTest` -- pinned to the same 1.11.1 as the rest of Compose Multiplatform, test-only -- and `runDesktopComposeUiTest(width = 1400)`, which gives a window that genuinely is 1400 pixels across at density 1. Four assertions, and they bite: swapping the last two modifiers makes the 1400dp case report `Actual width is 1400.0.dp, expected 480.0.dp`, which is the "centred but never capped" failure the doc comment names. The same test also pins `currentBreakpoint()` to the real window at all five widths -- 400, 700, 1000, 1400, 1800 -- with the screen margin following. A version of it that measured the parent's constraints rather than the window would answer `Compact` everywhere and pass every unit test in the suite. 37 theme tests green; android and desktop both compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b5bb0042b1 |
refactor: take the chat transcript out of the view model it was living in
Phase 6, step 7, and the prerequisite for the pane work rather than a tidy-up: the transcript has to render at 400dp as a whole screen and at 900dp as the detail half of a two-pane layout, and a layout that lives in a view model cannot be composed twice or previewed once. `ChatMessageListViewModel` was 1,113 lines, of which 380 were `RenderMessages` -- a `@Composable` member function holding a `LazyColumn`, a `DropdownMenu`, `Card`s and both of the app's only two `BoxWithConstraints` -- plus three private composables under the class. It is now 356 lines of state and coroutines, and `ui/composable/widgets/chat/ChatTranscript.kt` is 779 lines of layout. **The move is verbatim.** `ProposalsAwaitingYouNotice`, `PrivateMessageNotice` and `RitualNotice` are byte-identical -- `diff` says so. `RenderMessages` becomes `ChatTranscript` and differs by exactly the signature line and fourteen references that had been resolving against the enclosing class and now say `viewModel.`. Nothing was rewritten while it was in the air; the diff is small enough to read line by line, which is the only reason to move 760 lines in one commit. **Why a parameter and not a receiver.** Keeping it as `fun ChatMessageListViewModel.ChatTranscript(...)` would have made the diff a single word, and left every one of those fourteen reads bare. `openMessageActionsFor` read bare says nothing about where it is kept; `viewModel.openMessageActionsFor` says it survives the composition, which is the fact a reader of a transcript needs and the one a pane split will make load-bearing. **Two imports the extraction nearly lost.** `androidx.compose.runtime.getValue` and `setValue` are used implicitly, by `by mutableStateOf`, so a "drop imports whose name does not appear" pass drops both and the five delegated properties stop compiling. The compiler caught it; noting it because the same pass over the next file will do the same thing. The earlier version of that pass also required an import's name not to follow a dot, which silently dropped every `Modifier.fillMaxWidth()`-shaped extension. `:composeApp:compileDebugKotlinAndroid`, `:composeApp:compileKotlinJvm` and the jvm test suite all green. The three `Icons.Filled` deprecation warnings in the new file came with the code and are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
03d1e8e3b1 |
feat: give the app the five breakpoints, and let the screen margin follow them
Phase 6, steps 1 and 2. The app had no notion of window width at all -- two
`BoxWithConstraints` in 30,000 lines of UI, both inside a view model -- so every
layout decision in it was made once, for a phone, and then rendered unchanged
into a 1800dp desktop window.
**The dependency question the plan asked to settle first.** `material3-adaptive`
publishes multiplatform under `org.jetbrains.compose.material3.adaptive`, with
android, desktop and ios variants; the ios ones carry `ios_arm64` and
`ios_simulator_arm64` attributes despite the `uikit*` artifact names, so the
targets this project declares on a mac resolve. Version **1.2.0**, not the newer
1.3.0-beta02, because that is the version the pinned material3 itself resolves:
`material3-adaptive-navigation-suite:1.10.0-alpha05` names `adaptive:1.2.0` in
its pom, and 1.3.0 would pull window-core 1.5.0 in beside the 1.4.0 the pinned
material3 compiled against. Nothing is lost by staying: 1.2.0 already computes
the large and extra-large breakpoints through `supportLargeAndXLargeWidth`, and
carries `ListDetailPaneScaffold` for the pane work. So steps 3-4 can use the
library scaffolds rather than a hand-rolled equivalent.
**`Breakpoint`** is the five-value enum -- compact / medium / expanded / large /
extra-large at 0 / 600 / 840 / 1200 / 1600dp -- with `ofWidth` as a pure function
so the thresholds are assertable without a Compose runtime. `TorchTheme`
classifies once and provides `LocalBreakpoint`, so no two screens can disagree
about the window they are both in.
It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`.
The latter also computes a `Posture` from the platform's fold state, which on
android reaches for `WindowInfoTracker` and an activity; this call sits in
`TorchTheme`, which wraps all 51 `@Preview` bodies in the tree, and a preview
context is not an activity. The pane scaffolds ask for posture themselves, at
the one place a fold changes the answer.
**Spacing now adapts, and exactly one value moves.** M3 publishes a margin per
breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else
that varies with window width. The scale itself is absolute: `space200` is 16dp
on a phone and 16dp on a desktop, and what adapts is which token a job reaches
for, not the token. So `screenMargin` goes 16 -> 24 at medium and holds there,
and `containerPadding`, `itemGap` and the rest do not move -- a card does not
become a different component because the window grew. Widening all of them is
the "everything breathes on a big screen" instinct, and it reads as a zoomed
phone rather than as a layout. A test asserts the non-movement, because that is
the edit a later reviewer would wave through.
Mechanically this made the eight semantic names constructor parameters instead
of `get()`s over the scale, so a breakpoint can reassign one without moving the
stop underneath it. Kotlin resolves a default expression against the parameters
before it, so each still reads its stop by name and still follows it when the
scale is overridden -- phase 2's `Spacing(space200 = 24.dp)` assertion holds
unchanged. The two instances are singletons because `LocalSpacing` is a
`staticCompositionLocalOf` and invalidates on identity, not equality.
**A test found a real defect while being written.** `ofWidth` was
`entries.last { width >= it.minWidth }`, which throws `NoSuchElementException`
below 0dp. A desktop window reports a zero size for the frame before its first
layout pass, and this is called from the theme on every composition, so the
crash would have arrived on a resize rather than on anything a user did. Now
total.
`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` both
green; 28 theme tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
043d725599 |
feat: draw the expressive loading indicator, and stop shouting the sign-out button
Phase 5, second step, of docs/material-design-conformance.md. Two smaller pieces, and a
correction to the plan.
**41 loading states stopped being a gold spinner.** `LoadingDataIndicator` wraps every wait
in the app, and it drew a `CircularProgressIndicator` hardcoded to 80dp in
`colorScheme.secondary` -- the brand gold, which reads as a warning rather than as a wait,
on a component that has a size of its own. It now draws `LoadingIndicator`, which is M3's
component for an indeterminate wait with no progress to report and the one
`MaterialExpressiveTheme` expects to be paired with. One wrapper changed; 41 call sites
follow.
**The profile screen had two maximum-emphasis buttons, and one of them was Sign out.**
Seven actions in one list: five `TextButton`s (edit profile, key packages, change account,
profile keys, network relays) and two filled `Button`s. A filled button is M3's highest
emphasis and is meant for one action per screen, so this was two competing primaries -- and
the more prominent of the pair was the list's most destructive item.
Sharing is now `FilledTonalButton`: it is the useful action, at medium emphasis rather than
maximum. Signing out is a `TextButton` in the error colour, which is not a new pattern --
it is how leaving and deleting a group are already treated in `ChatRoomDetailScreen`.
Screenshot verified on emulator-5554: one tonal button, one red text button, five plain
ones, and a hierarchy a reader can follow.
**The plan was wrong about disabled FABs, and the code was right.** It said five screens
should stop hand-computing a container colour from a `can…` flag and pass `enabled`
instead. **No `FloatingActionButton` overload in material3 1.10 takes `enabled`** -- checked
in the source, zero matches for `enabled: Boolean` in FloatingActionButton.kt -- because
the spec's own position is that an unavailable FAB should not appear at all. Hand-computing
is the only way to show a disabled one.
More to the point, the existing code is already better than the plan assumed: it pairs the
colour with `Modifier.semantics { disabled() }` and a comment saying "looking unavailable
is not being unavailable: without this a screen reader still announces a button it is happy
to press." Left alone, and the plan corrected.
**Eight screens are left for a person.** LandingScreen puts "Sign in" beside "Create
profile", SocialPreconditionScreen puts "Invite a friend" beside "View invites", and six
others do the same. Both members of each pair are filled buttons. Which one is primary is a
product decision about what the screen is *for*, and picking wrong quietly weights a choice
the user is supposed to make freely -- so this is listed in the plan rather than guessed at
here.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. Both
changes are composition-time rendering, which this repo has no UI test infrastructure to
assert; the device screenshot stands in for it. `:composeApp:compileDebugKotlinAndroid`
builds and the apk runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
1f24aaf4bb |
fix: give the two single-field screens their initial focus, and check 200% text on a device
Phase 3, final step, of docs/material-design-conformance.md. The tree had **zero** uses of
`FocusRequester`, `LocalFocusManager` or `focusProperties`, so no screen defined where
keyboard focus starts.
**Two places get it, and only two.** M3's flow guidance asks for an initial focus per
screen and, for a dialog, that "focus is set to the dialog component, likely to a specific
interactive element within the dialog such as a text input field":
- `StartDirectMessageToNpubOrNip05Dialog` -- one field and two buttons. Without this the
dialog opens with nothing focused, so a keyboard or switch user tabs in from wherever
focus happened to be.
- The desktop `PassphraseGate` -- the first screen of the desktop app, whose entire
content is one field, and where there is no tap to give it focus. Somebody who opens
the app and starts typing should not have to reach for the mouse first.
The other seven text-field screens deliberately do **not** auto-focus. Requesting focus
raises the software keyboard, and on a screen that leads with content somebody wants to
read -- AddArtifact's chapter list, WriteNewNote's reply preview -- that covers the thing
they came for. M3 asks for the initial focus to be *defined*, not for a field to be
grabbed; on those screens the definition is "the top of the content".
**Large text verified on a device rather than reasoned about.** Two passes:
A static one first, since the failure mode is a fixed height around text. All 23 fixed
vertical dimensions outside `Spacer`s are icons, images and progress indicators -- 12 to
40dp `.size()` calls, a 200dp image, a 180dp `heightIn` cap. Nothing wraps text in a fixed
box.
Then at `font_scale 2.0` on an API 36 emulator, three screens: onboarding, the message
list, and a chat room. All reflow without clipping. The chat room is the useful one --
system messages wrap to two lines and their timestamps and chevrons stay aligned, the
composer keeps its full width, and the transcript stays readable. `font_scale` was put
back to 1.0 afterwards.
The physical device attached to this machine was left alone. `font_scale` is a
system-wide setting and changing it on somebody's actual phone to test an app is not a
reasonable thing to do; a fresh emulator was booted for it instead.
**An unrelated flaky test, measured and left alone.** `ChronicleApplyJvmTest > an answered
catch-up leaves one line, whatever it took to deliver` failed once during this commit's
verification with
expected:<[chronicleRequested, chronicleReceived]> but was:<[chronicleReceived, chronicleRequested]>
and reproduces at **1 failure in 8** consecutive `--rerun` invocations on this tree. Both
transcript lines are written within the same second and the DAO's ordering has no
documented tie-break, so either order can come back. That is chronicle and database code;
nothing in this branch touches it. Whether it is a test bug or a real one -- two lines
swapping places in a user's transcript on reload would be a defect -- wants deciding by
somebody in that code, so it is filed rather than patched here.
**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. Focus and
window insets are properties of a running composition; there is no Compose UI test
infrastructure here, and a test asserting the modifier is present would restate the diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9dc748f2d3 |
fix: lift the eight text-field screens above the software keyboard
Phase 3, third step, of docs/material-design-conformance.md. Nine screens and a dialog carry text fields. One of them called `imePadding()`; the audit had said seven screens, and was wrong about that too. `Scaffold`'s `contentWindowInsets` defaults to `systemBars`, which does **not** include the ime, so a `Scaffold` on its own does nothing about a keyboard covering the field being typed into. `Modifier.imePadding()` on the Scaffold lifts the whole screen, which is the standard shape and the one that needs no per-field handling. Eight screens get it: AddArtifact, AddChapter, AddDialect, TranslateChunk, ChatRoomCreation, CreateProfile, SignIn, WriteNewNote. Each carries a one-line comment saying why, since a bare modifier in a Scaffold argument list is the kind of thing that gets deleted in a cleanup. **ChatRoomMessagingScreen is deliberately not one of them**, and the reason is now written where somebody would look for it. Its composer already reserves its own bottom inset with `navigationBarsPadding()`. Adding `imePadding()` to the Scaffold as well would pad twice while the keyboard is up, because the ime inset already covers the navigation bar area that row is separately reserving. Getting that combination right wants a device with a keyboard open, not a compiler, and it is the one screen where the existing code shows signs of having been tuned by hand. **Not device-verified, and worth saying so plainly.** The emulator's account boots to a populated home screen, and reaching any of the eight means several hops through onboarding; what was confirmed is only that the keyboard interaction works on the path that was reachable -- the npub dialog's field moved from a bottom edge of y=1250 to y=840 with `mInputShown=true`, so ime handling is live on this build. The eight Scaffolds themselves were not each opened with a keyboard up. `ChatRoomCreationScreen`, reached through New Chat -> Start a group chat, is the shortest path for whoever checks. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. Window insets are a property of a running composition against a real window; there is no Compose UI test infrastructure here to assert them, and a test that the modifier is present would only restate the diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |