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>
536 lines
26 KiB
Markdown
536 lines
26 KiB
Markdown
# 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](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`:
|
|
|
|
```kotlin
|
|
/**
|
|
* 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`](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt) —
|
|
same package, same shape, same reasons:
|
|
|
|
```kotlin
|
|
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 ` ST ` still 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 make `sw` · Tanzania held;
|
|
- a row with a code the catalogue does not list changes nothing;
|
|
- `dialectsOf` emits `DialectEvent.KIND`, in catalogue order, all with the one
|
|
`createdAt`, and each round-trips through `MantraDialect.fromDialectEventTemplate`
|
|
back 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`:
|
|
|
|
```kotlin
|
|
/**
|
|
* 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`, as `AddDialectRoute` has.
|
|
- `ui/view/state/AddDialectsFromCatalogueUIState.kt` — `Loading` / `Error` /
|
|
`Loaded(localChatRoom, rows: List<CatalogueRow>, selected: Set<String>, canSign)`,
|
|
where a `CatalogueRow` is an `AfricanLanguage` plus its state from the table in
|
|
phase 3.
|
|
- `ui/view/model/AddDialectsFromCatalogueViewModel.kt`
|
|
- `ui/composable/AddDialectsFromCatalogueScreen.kt`
|
|
- the `composable<…>` arm in `MantraNavHost.kt`, copying `AddDialectRoute`'s
|
|
`popUpTo` so 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:
|
|
|
|
```kotlin
|
|
fun proposeSelectedDialects(
|
|
localChatRoom: LocalChatRoom,
|
|
selected: List<AfricanLanguage>,
|
|
onSuccess: (sessionId: String) -> Unit,
|
|
onFailure: () -> Unit,
|
|
)
|
|
```
|
|
|
|
- empty selection fails fast rather than opening an empty session;
|
|
- `isActionPending` guards the double tap, as every proposing view model here
|
|
does;
|
|
- one `TimeUtils.now()` for the wave;
|
|
- `selected.chunked(FrostSigningManager.MAX_BATCH_SIZE)`, one
|
|
`proposeSigningBatch(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 `onSuccess` callback invoked from `Dispatchers.Main`. This is not
|
|
decoration: the callback navigates, and a navigation from `Dispatchers.IO`
|
|
crashes. 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 `ChunkEvent`s 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`](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt):
|
|
|
|
- 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](README.md) pointing here.
|
|
|
|
```bash
|
|
./gradlew :composeApp:m3Audit
|
|
```
|
|
|
|
```bash
|
|
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.py` skips
|
|
`\s*(//|\*|/\*)` and `m3-title-case.py` skips the same three prefixes as a
|
|
`startswith` tuple. 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 `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; 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.
|