Merge branch 'mantra' into claude/curated-kmp-ios-build-plan-ec280d
The african-dialect line, nine commits, against the four landed ios phases. No conflict: the only file both sides touched is docs/README.md, where each added its own table row, and mantra left the library pin alone. The merge is worth a note anyway, because it could have undone Phase 3. That phase took the commas out of 84 commonTest names to get linkDebugTestIosArm64 past Kotlin/Native's "Name contains illegal characters: ','", and mantra arrives with five new commonTest files. They happen to carry none, so the invariant holds -- but it holds by luck rather than by anything enforcing it, and the next branch to merge may not be so tidy. jvmTest 1221, testDebugUnitTest 599, the library's 159, and the m3 audit's ten counts all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ silent, or a decision that looked arbitrary and was not.
|
||||
| [ios-build.md](./ios-build.md) | building for an iPhone, following the route the Curated fork walked — the five defects between here and a device, why the fork's "linux cinterop" fix is the one that makes a mac work, and the one annotation that unblocks native by changing every other target |
|
||||
| [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes |
|
||||
| [chapter-uploads.md](./chapter-uploads.md) | picking files off a device and having the group sign each one in as a chapter — why N files can never be one signing session, and the four things the single-chapter path gets away with |
|
||||
| [african-dialect-catalogue.md](./african-dialect-catalogue.md) | filling a group's dialects from a bundled catalogue of the languages most spoken in Africa — why a dialect is a language *in a country*, why nothing in the schema makes one unique so duplication has to be stopped at proposal time, and the race two admins hit if the picker cannot see what is already being signed |
|
||||
| [project-feature.md](./project-feature.md) | bringing the server schema's `project` and its two join models into a group that has no accounts and no server — a creator tag of its own and owners as a signed set of `p` tags, the role the parser has to be told about or every project lands ownerless, why a membership is its own signed event rather than a list, and the kind band that is already double-booked |
|
||||
| [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme |
|
||||
| [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand |
|
||||
@@ -89,6 +90,17 @@ built, because each of those is wrong in the single-chapter path today. Its two
|
||||
splitter phases are separable from the rest and worth reading on their own: what
|
||||
a chunk is, measured against the parser the web app translates with rather than
|
||||
assumed.
|
||||
The African dialect catalogue note is a phased plan that has been built. It is
|
||||
the smallest of the nip30303 plans and the only one whose hard part is an
|
||||
identity rule rather than a ceremony: a dialect's id is a content hash including
|
||||
`createdAt`, there is no unique index, and the rows are quorum-signed, so two
|
||||
proposals of the same dialect are two rows and neither is wrong by any rule the
|
||||
schema knows. Read its two framing sections before its phases -- they are what
|
||||
every phase is built to keep -- and read the batch-signing note first if the
|
||||
all-or-nothing rule is not already familiar. Its last phase is a fix the feature
|
||||
makes necessary rather than part of it, which is worth knowing before reading the
|
||||
diff.
|
||||
|
||||
The projects note is a phased plan that has been built as far as its seventh
|
||||
phase, and is the only one
|
||||
whose starting point is outside this repository: the platform's server schema,
|
||||
|
||||
535
docs/african-dialect-catalogue.md
Normal file
535
docs/african-dialect-catalogue.md
Normal file
@@ -0,0 +1,535 @@
|
||||
# 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.
|
||||
@@ -14,6 +14,14 @@ a pointer on desktop has to land on.
|
||||
Components that carry their own minimum -- IconButton, Button, Checkbox, ListItem and the
|
||||
rest of material3 -- are not `.clickable` call sites and never appear here.
|
||||
|
||||
Comments are not code, in either direction. A KDoc line explaining that a row uses a
|
||||
clickable `Card` *rather than* a `Modifier.clickable` was counted as a call site, which
|
||||
breaks a zero budget and so breaks `./gradlew check`; and a comment above a real call site
|
||||
that merely mentions `minimumInteractiveComponentSize` would have excused it. The first is
|
||||
noise and the second is a miss, so both lines of the window are read as code only. This is
|
||||
the same thing m3-audit.sh's `NOT_A_COMMENT` exists for, spelled as m3-title-case.py
|
||||
spells it -- see the note there on a colour comment counting as a colour.
|
||||
|
||||
Usage: m3-touch-targets.py [--list]
|
||||
"""
|
||||
import os, re, sys
|
||||
@@ -21,6 +29,18 @@ import os, re, sys
|
||||
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
|
||||
|
||||
|
||||
def _is_comment(line):
|
||||
"""Whether `line` is a comment line: `//`, or a `/*` block's opener or body.
|
||||
|
||||
A prefix test rather than block tracking, because that is what the other two checkers
|
||||
do and three identical rules spelled three ways is worse than one limitation. The
|
||||
limitation: a `/* ... */` body line that does not start with `*` reads as code. There
|
||||
are 539 block comments under `ui/` and not one such line, since every KDoc here stars
|
||||
its body -- if that ever stops being true, this is the function to make stateful.
|
||||
"""
|
||||
return line.lstrip().startswith(('//', '*', '/*'))
|
||||
|
||||
|
||||
def offenders():
|
||||
found = []
|
||||
for root, _, files in os.walk(UI):
|
||||
@@ -32,11 +52,14 @@ def offenders():
|
||||
for i, line in enumerate(lines):
|
||||
if '.clickable' not in line:
|
||||
continue
|
||||
if line.lstrip().startswith('//') or line.startswith('import '):
|
||||
if _is_comment(line) or line.startswith('import '):
|
||||
continue
|
||||
# The modifier may sit on this line or on the one above, since a chain
|
||||
# broken across lines is the common shape.
|
||||
window = '\n'.join(lines[max(0, i - 1):i + 1])
|
||||
# broken across lines is the common shape. Comment lines are dropped from
|
||||
# the window rather than kept: an exemption has to be in the code.
|
||||
window = '\n'.join(
|
||||
l for l in lines[max(0, i - 1):i + 1] if not _is_comment(l)
|
||||
)
|
||||
if 'minimumInteractiveComponentSize' in window:
|
||||
continue
|
||||
found.append((path, i + 1, line.strip()[:90]))
|
||||
|
||||
Reference in New Issue
Block a user