A KDoc line on the dialect picker's row composable, explaining that it used `Card(onClick = …)` rather than a `Modifier.clickable`, was counted as a bare clickable chain. The budget is zero, so a sentence about the rule broke `./gradlew check`. The comment was reworded to get around it, which was the wrong fix -- the sentence was right -- and it is restored here. Not a new rule. m3-spacing-positions.py skips `\s*(//|\*|/\*)` and m3-title-case.py skips the same three prefixes as a startswith tuple; only this checker was missing the two block-comment ones, having been written with `//` alone. m3-audit.sh explains the whole problem at NOT_A_COMMENT, where a note about a hardcoded colour once counted as a colour. The same function had the mirror bug, which is the dangerous direction. It looked for minimumInteractiveComponentSize in a two-line window without stripping comments, so a comment above a real bare `.clickable` that merely mentioned the modifier would have excused it. A false positive is noise; a silently excused offender is a miss. Both lines of the window are now read as code only. Checked against six cases: a bare clickable is still caught, a guard on the same line and a guard on the line above still excuse one, a starred KDoc no longer counts, and a real offender under a comment naming the modifier is now caught. Counts for the tree are unchanged -- 0 before and after -- which was worth establishing, since a broken detector would also report zero. A prefix test rather than block tracking, and the limitation is written on the function: a `/* ... */ `body line not starting with `*` still reads as code. There are 539 block comments under ui/ and not one such line, because every KDoc here stars its body, and three identical rules spelled three ways would be worse than one limitation on the record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26 KiB
A catalogue of African languages, and dialects proposed in waves
How a group fills its dialect list from a bundled catalogue of the languages most spoken in Africa instead of typing each one into a three-field form, and how admins top that list up later — proposing only what is still missing, in waves, without two of them proposing the same wave twice.
Built. All seven phases are in, one commit each, and the phases below are kept as written -- they are the reasoning, and the code is easier to read against the argument it came from than against a summary of itself. Where the implementation chose differently the table at the end says so.
AfricanLanguages.ALL is the catalogue, DialectScaffold answers what a room is
missing, and AddDialectsFromCatalogueScreen is the picker, reached from the
group's detail screen beside "Add dialect".
Two facts shape every phase and are stated before them, because both are easy to design around and expensive to discover afterwards.
A dialect is a language in a place
MantraDialect is (name, country, language) — "Sesotho", "Lesotho", st. The
language column holds an ISO 639 code; the form's placeholder has always said
so (eg. st). So the catalogue's unit is not a language but a language paired
with a country, and Swahili-in-Kenya and Swahili-in-Tanzania are two entries.
That is the feature rather than a duplication: a group translating for Kenyan
readers is not translating for Tanzanian ones, and the whole reason this kind is
called a dialect is that it is finer than a language.
Nothing anywhere makes a dialect unique
A dialect's id is EventHasher.hashId(pubKey, tags, content, createdAt, kind)
over the room's key. Two proposals of Sesotho-in-Lesotho a minute apart hash
differently, are two MantraDialect rows, and neither is wrong by any rule the
schema knows. There is no unique index, and adding one would reject a legitimate
re-signing as readily as a duplicate.
So duplication is prevented at proposal time or not at all, and the handle
has to be (code, country). Not the name: a member typing "Sesotho" and a
catalogue saying "Sesotho" agree today and disagree the moment one of them
carries a diacritic, and the name is the one field of the three that is a matter
of spelling rather than of fact.
A session signs at most 64 events, all or nothing
FrostSigningManager.MAX_BATCH_SIZE is 64 and MAX_PROPOSAL_BYTES is 64 KiB,
and a batch that cannot aggregate one item applies none of them —
frost-batch-signing.md says why, and says a retry must
be a fresh batch rather than the same one again.
The catalogue below is about fifty entries, so a first wave is one session. Both properties still have to be defended by a test, because a catalogue that grows to sixty-five silently becomes two sessions and a reviewer adding an entry has no reason to be thinking about FROST.
The all-or-nothing risk is answered by the same rule that prevents duplication: because the picker proposes only what is missing, re-proposing a failed wave is safe and lands exactly the entries the failed one did not. "It is safe to ask again" is what the signing screen already tells a member of a failed session, and here it is true without qualification.
Phase 1 — the catalogue, as data
Half a day. No behaviour change; nothing reads it yet.
New file,
composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt:
/**
* A language the catalogue offers, in one of the countries it is spoken in.
*
* The unit is the pair, not the language: a dialect is `(name, country, code)`,
* so Swahili appears once for Kenya and once for Tanzania. See the note on
* identity in docs/african-dialect-catalogue.md.
*/
data class AfricanLanguage(
/** What the dialect row will be called. The endonym where there is a settled one. */
val name: String,
/** ISO 639-1 where the language has one, ISO 639-3 otherwise. */
val code: String,
val country: String,
)
object AfricanLanguages {
/** Ordered by country, so a reader scanning for theirs scans once. */
val ALL: List<AfricanLanguage> = listOf(/* … */)
}
Where it lives matters. press/mantra/compose/language/ rather than
anywhere under ui/, because docs/scripts/m3-audit.sh scans ui/ and
strings.xml for string literals and title case, and "Northern Sotho" is title
case for exactly the reason m3-title-case.py keeps a SAMPLE set of proper
nouns at all — it is one. Putting reference data outside ui/ is cheaper and more honest than an
m3-string-exempt marker on a file that is not UI at all.
It is also not a composeResource. There is no files/ directory under
composeResources today, a bundled JSON would need a parser and a failure mode,
and a Kotlin list is compile-checked, greppable and testable. The catalogue is
reference data in the same sense SMALL in m3-title-case.py is.
Codes: 639-1 where it exists, 639-3 otherwise. st, sw, zu, ha — and
nso, bem, luo, mos, kab for the ones ISO never gave two letters. The
alternative, 639-3 throughout, is more uniform and worse here: it would make the
catalogue's sot disagree with the st every room that used the form has
already signed, and those rows cannot be rewritten — they are signed by a
quorum.
Roughly fifty entries, covering the cross-border lingua francas and the high-population national languages: Swahili (Tanzania, Kenya), Hausa (Nigeria, Niger), Yoruba, Igbo, Fulfulde, Amharic, Oromo, Tigrinya, Somali, Arabic (Egypt, Sudan, Morocco), Zulu, Xhosa, Afrikaans, Northern Sotho, Southern Sotho, Tswana, Tsonga, Venda, siSwati, Shona, Northern Ndebele, Chichewa, Bemba, Lingala, Kikongo, Tshiluba, Kinyarwanda, Kirundi, Luganda, Kikuyu, Dholuo, Akan, Ewe, Ga, Wolof, Bambara, Mooré, Malagasy, Sango, Umbundu, Kimbundu, Makhuwa, Tamazight, Kabyle, Krio.
One judgment call to settle in review. French, Portuguese and English are
among the most spoken languages on the continent and are not African languages.
Including them makes the catalogue useful to groups that actually translate into
them; leaving them out keeps it a catalogue of African languages. The plan
includes three entries — fr · DR Congo, pt · Angola, en · Nigeria — as one
contiguous, clearly commented block, so deleting them is a four-line change
rather than an argument.
Test: composeApp/src/commonTest/.../language/AfricanLanguagesTest.kt —
every code lowercase and two or three characters, every name and country
non-blank, (code, country) unique across the list, and the list ordered as it
claims to be. The uniqueness assertion is the one that earns its place: a
duplicate pair in the catalogue would propose the same dialect twice in one wave,
and the two would not even collide, because their ids differ.
Acceptance: :composeApp:compileDebugKotlinAndroid and the new test pass;
:composeApp:m3Audit unchanged.
Phase 2 — DialectScaffold: what is missing, and the events for it
Half a day. Pure functions. Still nothing calls them.
New file,
composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt,
built as a deliberate parallel to
TranslationScaffold —
same package, same shape, same reasons:
object DialectScaffold {
/** The catalogue entries this room has no dialect for, and none in flight. */
fun missingFrom(
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
existing: List<MantraDialect>,
pending: List<AfricanLanguage> = emptyList(),
): List<AfricanLanguage>
/** One dialect event per entry, stamped with one timestamp for the wave. */
fun dialectsOf(
languages: List<AfricanLanguage>,
createdAt: Long,
): List<EventTemplate<DialectEvent>>
}
missingFrom matches on (code, country) normalised with trim() and
lowercase() — the no-argument lowercase() deliberately, which in Kotlin
common is locale-invariant, so a Turkish device does not decide that TI and
ti are different languages. The name is ignored for matching and carried
through for display.
dialectsOf takes one createdAt for the whole list rather than reading the
clock per template, for the reason TranslationScaffold.chaptersOf does: a wave
proposed as one act should read as one act, not as fifty dialects that happen to
share a minute. It also makes the wave's event ids a function of its inputs,
which is what makes the test below possible.
Test: composeApp/src/commonTest/.../managers/DialectScaffoldTest.kt,
mirroring TranslationScaffoldTest:
- a room with no dialects is missing the whole catalogue;
- a room holding
st· Lesotho is missing everything else and not that; - a row whose code is
STstill counts as held; - a row whose name is spelled differently but whose code and country match still counts as held — the assertion that pins the identity rule;
sw· Kenya held does not makesw· Tanzania held;- a row with a code the catalogue does not list changes nothing;
dialectsOfemitsDialectEvent.KIND, in catalogue order, all with the onecreatedAt, and each round-trips throughMantraDialect.fromDialectEventTemplateback to the name, country and code it came from.
Acceptance: compile and the new test pass.
Phase 3 — what is already in flight
Half a day. Read-only; the first phase that touches a repository.
A catalogue entry is in one of three states, and the picker is wrong without all three:
| state | how it is known | selectable |
|---|---|---|
| in the room | a MantraDialect row for (code, country) |
no |
| in flight | an open signing session holds a kind-30304 event for (code, country) |
no |
| missing | neither | yes |
Without "in flight" the feature has a race it will hit on its first real use: two admins open the picker, both see the same forty missing, both propose, and the room gets eighty dialects — forty of them duplicates that nothing will ever merge, because nothing makes a dialect unique.
Everything needed is already on FrostSigningRepository:
observeSessionsForChatRoom(chatRoomId) gives LocalFrostSigningSession, each
with its items, and unsignedEvents(items) turns those into events at
proposal time — before any signature, which is precisely when this has to be
readable.
Add one more pure function to DialectScaffold:
/**
* The catalogue entries an open session is already signing.
*
* `FrostSigningStage.FAILED` and `COMPLETE` are both excluded, for opposite
* reasons: a failed wave left nothing behind and must be proposable again --
* the signing screen says as much -- and a complete one is a row by now, so
* counting it here would hide it behind the wrong explanation.
*/
fun pendingIn(
sessions: List<LocalFrostSigningSession>,
unsignedEvents: (List<FrostSigningItem>) -> List<Event>,
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
): List<AfricanLanguage>
The unsignedEvents lambda is passed in rather than imported, for the reason
ProposedEvent.summarize takes resolve: it keeps the function pure and
testable without a database behind it.
Test: commonTest, with hand-built FrostSigningItem rows holding
DialectEvent.build(...) JSON. Cases: an open session's dialects are pending; a
FAILED session's are not; a COMPLETE session's are not; a session signing
chapters contributes nothing; an item whose JSON will not parse is skipped rather
than throwing — a malformed item must not take the picker down with it.
Acceptance: compile and the new test pass. No wire change, no schema change.
Phase 4 — the picker
Two days. The screen, and the way in.
A new pushed screen beside the hand-typed form, which stays exactly as it is.
Five files, following the house pattern (route · state · view model · screen ·
registration) and SelectSubgroupAdminsScreen for the multi-select mechanics:
ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt—activeUserPublicKey,chatRoomId,relayHint, asAddDialectRoutehas.ui/view/state/AddDialectsFromCatalogueUIState.kt—Loading/Error/Loaded(localChatRoom, rows: List<CatalogueRow>, selected: Set<String>, canSign), where aCatalogueRowis anAfricanLanguageplus its state from the table in phase 3.ui/view/model/AddDialectsFromCatalogueViewModel.ktui/composable/AddDialectsFromCatalogueScreen.kt- the
composable<…>arm inMantraNavHost.kt, copyingAddDialectRoute'spopUpToso back does not return to a form whose proposal has gone out.
State goes in a StateFlow, not mutableStateOf. The existing
AddDialectViewModel uses mutableStateOf and gets away with it because it
writes the state once. This screen does not: the loaded state is written from
Dispatchers.IO, and a collector on observeSessionsForChatRoom folds the
pending list into it, and the UI read-modify-writes the selection. That is the
case where a mutableStateOf in a view model loses writes — a
MutableStateFlow with update { } is the shape that does not.
The selection may live beside it as mutableStateListOf<String>() of
"$code|$country" keys, which is what SelectSubgroupAdminsViewModel does with
selectedPublicKeys; the point is that it is not folded into a mutableStateOf
holding the whole UI state.
What the screen shows. A TopAppBar with NavigateBackButton (it is pushed;
the desktop target has no system back), a search field filtering on name, code
and country, and a LazyColumn of ListItem rows grouped by country. Each row
by state:
- missing — a
Checkbox, enabled, toggling selection; - in the room — a
Checkbox(checked = true, enabled = false, onCheckedChange = null)and a supporting line saying so, exactly as the subgroup screen renders the coordinator who is an admin by construction; - in flight — disabled,
Modifier.semantics { disabled() }, supporting line "waiting for the group to sign", with the pending wave reachable from the room's proposal list.
A header line carries the count — "12 of 50 selected" — and a "select everything missing" action, which is the whole convenience the feature is named for.
All four screen states, per the house rules: ScreenStateTransition wrapping the
when, ErrorState with a real onRetry (re-running initiate() is a genuine
retry here, so passing null would be the wrong decision), LoadingDataIndicator,
and an EmptyState with its own message for the case worth getting right — a
room that already holds the whole catalogue. "No dialects found" would be a lie
there; the message is that the catalogue has nothing left to add and the form is
the way to add one it does not list, with that form as the EmptyState's
action.
canSign gates the FAB the way AddDialectScreen does, with the same
explanation of why, because a group with no shared key cannot sign a dialect into
existence at all.
Spacing from MaterialTheme.spacing, colour from roles, strings from
strings.xml in sentence case, every target 48dp, every icon's
contentDescription decided, Modifier.padding(innerPadding).readableContent()
on the content root. About a dozen new strings. The language names are not
among them: they come from the catalogue, which is data.
The way in: a second TextButton under the dialect list on
ChatRoomDetailScreen, beside "Add dialect" — "Add dialects from a list". The
existing button is untouched.
Test: a @ConformancePreviews preview per state, as every screen here has,
with FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY. Row-state logic is
phase 3's and is tested there rather than through the composable.
Acceptance: :composeApp:m3Audit reports no new budget breach and no drop
below either floor; compileDebugKotlinAndroid and jvmTest pass; the previews
render in all six schemes.
Phase 5 — proposing the wave
A day. The first phase that changes what a group sees.
One method on the view model, built on
TranslationArtifactVersionDetailViewModel.catchUpMissingChapters — which is the
same problem, down to the chunking:
fun proposeSelectedDialects(
localChatRoom: LocalChatRoom,
selected: List<AfricanLanguage>,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit,
)
- empty selection fails fast rather than opening an empty session;
isActionPendingguards the double tap, as every proposing view model here does;- one
TimeUtils.now()for the wave; selected.chunked(FrostSigningManager.MAX_BATCH_SIZE), oneproposeSigningBatch(localChatRoom, userPublicKey, events)per chunk — the flat form, since dialects name nothing and need no lead;- navigate to the first session with
popUpTo<AddDialectsFromCatalogueRoute> { inclusive = true }, the rest being in the room's proposal list beside it; - the
onSuccesscallback invoked fromDispatchers.Main. This is not decoration: the callback navigates, and a navigation fromDispatchers.IOcrashes. The handoff belongs inside the view model, which is where the existing ones put it.
With fifty entries the chunking never fires. It is one line, it is the established idiom, and it is what stops the sixty-fifth catalogue entry from being a bug instead of a second session.
Nothing else needs changing to make a wave readable.
ProposedEvent.summarize already has its DialectEvent.KIND arm — "New dialect"
over "Sesotho · Lesotho · st" — and WhatIsBeingSigned already leads a batch
with "N events signed together" and renders every item, refusing to let any of it
be signed unless all of it parsed. Fifty two-line blocks in a scrolling Column
is a long screen, and that is the correct length: a member is being asked to put
the group's name to fifty dialects, and the batch is all-or-nothing.
Test: composeApp/src/jvmTest/.../managers/DialectWaveProposalJvmTest.kt,
modelled on TranslationBatchProposalJvmTest — over a real in-memory database
and a real key, assert that a wave of N opens one session with N items in
catalogue order, that each item's unsigned event is a kind-30304 whose tags round
-trip to the catalogue entry, that the events share one createdAt, and that a
selection of 65 opens two sessions with 64 and 1.
Acceptance: the above, plus a manual pass: pick five, propose, approve on enough devices, see five dialect rows appear on the group detail screen.
Phase 6 — one transcript line for a wave, not fifty
Half a day. A behaviour fix the previous phase makes necessary.
FrostSigningManager.applySignedEvent hands each signed event to
ChatMessage.applyInnerEvent and files whatever chat line comes back. The
dialect arm returns one — "Added Sesotho to dialects" — so a wave of fifty posts
fifty lines into the group's conversation, followed by the session's own "The
group signed 50 events".
This is not a problem the existing batches have, and the reason is instructive.
Of the kinds applyInnerEvent files a line for — chat, artifact, chapter,
dialect, translation, project — every one that can appear many-per-batch already
returns null for exactly this reason: a chunk is part of a chapter and the
chapter's line speaks for it; a translation chapter, a contributor list and both
project memberships likewise. DialectEvent is the first kind where a batch
holds many peers each of which wants a line of its own.
So the rule, stated once where the batch is visible:
A signed event files its chat line unless the batch holds more than one event of its kind, in which case the session's completion line speaks for them.
Implemented as a fileChatLine: Boolean parameter on the private
applySignedEvent, computed in its one caller as
signedEvents.count { it.kind == signedEvent.kind } == 1. applyInnerEvent is
untouched — it still writes the MantraDialect row, which is the part that
matters — and only the chatMessageDao().upsert is skipped.
Checked against every batch the app builds today, this changes nothing: a chapter
batch is one ChapterEvent and N ChunkEvents that file nothing; a translation
batch is one TranslationArtifactVersionEvent and N translation chapters that
file nothing; a project batch is one ProjectEvent and N memberships that file
nothing. The only batch it affects is the one phase 5 introduces.
ChronicleManager.applyPage needs nothing: it already drops the chat line a
payload returns, and says why.
Test: extend the jvmTest that covers applySignedEvent's effects — a batch
of one dialect still files its line; a batch of three files none and still writes
three MantraDialect rows; a batch of a chapter and its chunks still files the
chapter's line.
Acceptance: the above, and a manual pass showing one completion line rather than fifty.
Phase 7 — the guards that keep it true
Half a day.
composeApp/src/commonTest/.../language/DialectCatalogueSizeTest.kt, modelled on
ChapterProposalSizeTest:
- the whole catalogue fits one batch —
AfricanLanguages.ALL.size <= FrostSigningManager.MAX_BATCH_SIZE; - the whole catalogue as a proposal fits
MAX_PROPOSAL_BYTES, measured the way the manager measures it rather than estimated; - and, because the first assertion will eventually fail on purpose, a failure message that says what to do about it: the chunking in phase 5 already handles it, and the test is lowered to a note rather than the catalogue trimmed.
Then the two commands from CLAUDE.md, and a line in
docs/README.md pointing here.
./gradlew :composeApp:m3Audit
ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest
What this deliberately does not do
No schema change, no migration, no new event kind, no wire change. The feature is a different way of building events the group already signs. Database version stays at 22.
No uniqueness constraint on MantraDialect. It would reject a re-signing as
readily as a duplicate, and the duplicate is prevented at proposal time instead —
see the note at the top.
No merging of dialects that are already duplicated. A room that typed "Sesotho" by hand and then selects it from the catalogue gets one row, because the codes match. A room holding two rows from before this feature keeps both; nothing here can remove a row a quorum signed.
No admin role. The user's framing was that admins propose in waves, and in
this app the gate on proposing is canSign — holding a share of the group's key
— rather than a role. Every member who can sign can propose a wave, and a quorum
decides. Introducing a dialect-proposing role would be a change to what a group
is, not a convenience method.
No automatic wave. Nothing proposes the catalogue on a room's behalf when it is created. A dialect is a statement the group signs about what it will translate into, and fifty of them arriving unasked is a statement nobody made.
Where the build chose differently
| the plan said | the build does | why |
|---|---|---|
| "about fifty entries" | 56 — 53 African, 3 European | the fan-out across countries landed there. 8 under MAX_BATCH_SIZE, which is thinner than it reads; phase 7's test is the tripwire |
one african list |
two, african and european, with ALL their sum |
the ordering test needs a list that is actually grouped by country, and appending the three European entries breaks that grouping. It also makes "drop them" one edit, which is what the plan promised |
missingFrom(existing, pending, catalogue) |
that, plus a public DialectScaffold.Key and three keyOf overloads |
the identity rule has four callers — the scaffold, the row classifier, the selection and the view model's unpick — and spelling (code, country) four times is four chances to forget trim() |
| phase 4 ships the picker with its FAB | phase 4 shows and picks, phase 5 proposes | a commit whose FAB calls a method written in the next commit does not compile. Split so each commit does something whole |
proposeSelectedDialects(localChatRoom, selected, …) |
the selection is read internally | the view model owns selected; passing it back in lets a caller pass something else, and nothing needs to |
CatalogueRow.rowsOf computes the three standings |
it derives them from missingFrom |
two functions deciding what is proposable is one too many. MISSING is whatever that function says, and held-versus-in-flight is a label on what it already excluded |
a wave "is small rather than just legal" — under a quarter of MAX_PROPOSAL_BYTES |
at most 602 bytes per entry | the catalogue encodes to 16,832 bytes, which is 25.7% of the budget — the quarter was a guess and it failed. The per-entry cost is the thing that would actually drift, and it does not move when a language is added |
One thing found on the way, and fixed
docs/scripts/m3-touch-targets.py read comments as code. A KDoc line on the
picker's row composable explaining that it used Card(onClick = …) "rather than
a Modifier.clickable" was counted as a bare clickable chain, which breaks a
zero budget and so breaks ./gradlew check. The comment was first reworded to
get around it, which was the wrong fix -- the sentence was right.
m3-audit.sh already solves this for the colour check and says why, at
NOT_A_COMMENT: "a note about a hardcoded colour counts as one -- which
happened the first time a call site was fixed and the commit explained what it
had replaced."
Fixed on 2026-10-03, and the KDoc restored. Two things were worth knowing while looking:
- The other two checkers already had it.
m3-spacing-positions.pyskips\s*(//|\*|/\*)andm3-title-case.pyskips the same three prefixes as astartswithtuple. Only the touch-target one was missing the two block-comment prefixes, so the fix is the house rule rather than a new one. - The same function had the mirror bug, which is the dangerous direction. It
looked for
minimumInteractiveComponentSizein a two-line window without stripping comments, so a comment above a real bare.clickablethat merely mentioned the modifier would have excused it. A false positive is noise; that is a miss. Both lines of the window are now read as code only.
The fix is a prefix test and not block tracking, deliberately: a /* … */ body
line that does not start with * still reads as code. There are 539 block
comments under ui/ and not one such line, because every KDoc here stars its
body -- and three identical rules spelled three ways would be worse than one
limitation written down.