Files
mantra-kmp/docs/scripts/m3-title-case.py

102 lines
4.5 KiB
Python
Raw Normal View History

fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
#!/usr/bin/env python3
"""Find UI strings still written in title case.
M3's style guide: "All text, including titles, headings, labels, menu items, navigation
components, app bars, and buttons should use sentence-style capitalization." Product names
and branded terms keep their capitals.
Two things this gets right that the first, grep-based version did not:
- It allows lowercase articles inside a title-cased phrase, so "Invite a Friend" is
caught. Requiring every word after the first to be capitalised missed four strings.
- It scans the whole file rather than one line at a time, so a `Text(` whose literal is
on the next line is caught. That missed one more.
Sample data is excluded by name rather than by pattern, because "Steve Biko" and "To Kill
a Mockingbird" are title case for the correct reason: they are a person and a book.
Usage: m3-title-case.py [--list]
"""
import os, re, sys
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
CATALOGUE = 'composeApp/src/commonMain/composeResources/values/strings.xml'
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
SMALL = {'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'via',
'in', 'on', 'at', 'with', 'from', 'by'}
# People, book titles and other proper nouns used as preview and test fixtures.
SAMPLE = {
'Steve Biko', 'John Doe', 'Frank Talk', 'Alan Turing',
'To Kill a Mockingbird', 'Man With A Plan', 'Woman Of Few Words',
feat(groups): the queue of a list the group curates, read off the relays it named `930d37c8` gave a group the schema: the definition of a list, signed by the room's own key, for strangers to answer. This is the answers. Under every schema card on the group's screen there is now a "View suggestions" button, and it opens the list's *queue* -- every kind 31888 anyone has published in reply to the schema's coordinate, newest first, whoever signed it, with a mark on each one the group has already taken up into the list. It is the second half of the curated-list NIP in this app, the read side of it: suggestions (31888) and canonical entries (31890) are now read and checked here. They are still not written; that is the third half. The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that site's one list: one row per suggestion event rather than one per film, so that a reader can see what is waiting before anything is added and whether the curator has taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the reference for everything the screen decides -- one row per event, the newest version per coordinate, "Curated" where a canonical entry shares the `d`, and a timer standing in for an answer that never comes. **The button is behind no gate, and it is per card.** Every other button in the identity block is hidden from a member holding no share of the key, because each of them proposes a signature by the group. The queue is the one thing in the block the group did not write. A schema exists to be answered by strangers, and reading the answers takes no share of anything, so the button is there for whoever is looking -- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a schema and finds it. It sits under its own card rather than once for the section, in the same `item {}` as the card, because each list has its own queue and a button between two cards would say nothing about which one it opened. **The protocol is ported rule for rule, and the fixtures are the NIP's own.** `nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`, `verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind is the one the schema names; every required field is present; no field without `repeat` appears twice; every value matches its field's type and config; every `require-any` group is met; the `a` root names this schema and no other; and the author is somebody the visibility admits. A canonical entry gets two rules on top -- its author is the schema's author, because curation is the one power a schema does not delegate, and a source pointer, when there is one, is a well-formed suggestion coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest` takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's example, and the curator's sign-off on it, and checks both against the bitcoin.mov schema from the same document; then the doc's own minimum suggestion, which has an IMDb link and no watch link and satisfies `require-any` that way; then every rejection the doc lists and says why it matters -- an unknown type, a year outside 1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a `d` with a space in it -- plus the reply rules, the repeat rule, the closed-list rule and the two canonical rules. **Rejected, not repaired, and read by field.** The NIP says a client MUST verify an entry against its schema and MUST reject one that does not satisfy it rather than showing it with the bad parts blanked out; relays carry malformed, partial and hostile events from other applications. So `suggestion` and `canonical` return the entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema. problems` is an enum rather than a sentence -- so that a form later can show them inline. What comes back is a `CuratedEntry` keyed by the schema's *field names* rather than by tag, because a field is what a form and a screen know an entry by and the tag is only where it was kept: the two `r` tags of the example land on `watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no field of the NIP's schema and are not in the reading -- "tags the schema does not define MAY be present and MUST be ignored". **A pattern this platform cannot compile counts as not matched.** The reference builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring an uncompilable pattern would accept entries the site refuses; refusing them is the conservative reading, and it is documented at the check. **A coordinate splits on the first two colons only.** A `d` may itself contain colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses -- so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`, and the test pins the identifier surviving its own colon and the pubkey being lowercased on the way through. **The queue is a reading over stored rows, with three things to close and one to add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the queue out of it. Closed: an event that does not satisfy the schema; a kind 31890 signed by anybody but the group, which is somebody else's list and marks nothing; and an older version of an entry its author has since replaced, since both kinds are addressable and different relays hold different latest versions, so the merge that realises an edit happens here, newest per `kind:pubkey:d` with the event id breaking ties. Added: `isCurated`, true where a canonical entry the group signed shares the suggestion's identifier -- the coordinate both land on, and the reference page's rule. Newest first, because that is the order a queue is read in. **A stranger is named by their profile, and by their key until one arrives.** The indexer writes a placeholder profile named "LOADING..." the moment a pubkey is first seen, and a queue full of that would be a queue of nobody. `suggesterName` reads the profile only when it is a real one -- `createdAt` past `GENESIS_AT` -- and otherwise the short key, which is at least stable and distinguishing. The row's avatar is tinted from the key like every avatar here, so two suggestions by one stranger read as one stranger. **Read from where the schema says, and the screen says where.** The schema's `relay` tags are signed into the event for exactly this: a client that finds the list anywhere knows where its replies live and cannot be sent elsewhere by an unsigned config. A schema naming none -- the NIP allows it and lets a client pick -- is read from the group's general relay list, the *read* relays of it, since this is a read; and failing an agreed, non-empty one, from this build's own relay, the same fallback the schema editor seeds from. Spellings are normalised so one relay written two ways is one request. The header names the relays asked, because a queue read from the wrong relays and a list nobody has written to look exactly alike from here, and a member should be able to tell them apart. **Nothing here talks to a relay; it is a pull like every other one-off read.** `CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay for the sync pump to drain, carrying the NIP's two queries as one request's two filters -- everything anyone published in reply to the coordinate, and what the *group* published in reply to it, the second scoped by `authors` to spare the relay the work `queueOf` does regardless -- and watches the local table for what comes back. That watch needed a way to observe an arbitrary NIP-01 filter, which the repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes and falls back to text notes for the rest, and none of its shapes is "these kinds with this `a` tag". `observeNostrEventsMatching` applies the filter whole through `NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is anchored and escaped rather than a substring scan; the DAO method behind it names its observed tables explicitly, the events and the profiles joined onto them, so a row whose author's kind 0 arrives after the row does re-emits with the name filled in. What the screen shows is therefore what this device has *stored* for the coordinate -- which is also what it shows with no network at all, and what it showed last time until the relays answer again. `NostrEvent.toEvent` is the small conversion the reading needs to hand a stored row to a verifier written against the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the group's own. **"Still looking" is bounded by time, because nothing else bounds it.** The pump marks a request sent when the REQ goes out and processed when an event comes back. A relay holding nothing for the coordinate answers with an EOSE the pump does not record, so there is no signal for "asked and empty", and a screen that showed the local table's first, empty read would call every list empty for the second before its rows arrived -- which teaches a member not to believe it. So the queue is "being asked" from the request going out until either something lands or ten seconds pass, and only after that is an empty queue empty. The reference store does the same with a six-second timer; ten here because the request queues behind the pump's four subscription slots before it goes out. Rows arriving end it early, a late arrival after it is still shown, and `withQueue` -- the one state merge -- pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers "Ask again", which re-queues the same requests: the one thing the watch cannot do on its own is make more arrive. **The row shows what an entry is, not where to find it.** The screen is driven by a schema it has never seen, so what goes on a row is a decision rather than a layout: the title as the headline, since every list has one; a glance line of the form fields that say what the entry is, as `Label: value`, because a bare "2014 · en" says nothing to somebody who does not know the list's fields; the body where the list has one; and who suggested it, when. Links are left to the sheet -- a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it a member might act on: it says the group already took this up, and re-curating it would revise the entry rather than add one. **The sheet has the whole entry and the event it came as.** Every field the entry answered, labelled as the schema labels it and in the schema's order, so it reads as the form would have; the title not repeated, since it is the heading; tokens and links monospaced because those are compared and copied rather than read. Then the signed event, byte for byte, with a copy button -- the way the key state sheet shows the group's own statement and for the same reason: a prettier rendering is a different string to the one whose id was hashed. It is also the thing a canonical entry is built from, when that half arrives. **Four states, and the transition between them.** Loading while the room and the schema are read; an error, with nothing to retry, for a schema this device does not hold, since the identifier came from a navigation argument and reading it again fails the same way; and a loaded state that is either the rows, or looking, or empty with something to do. `ScreenStateTransition` wraps the `when`, which is the Scaffold's whole content. **The audit's proper-noun list grows by two films.** The preview suggests *The Rise and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and `m3-title-case.py` excludes sample data by name rather than by pattern. **Still nothing has reached a relay, and it bites once more.** Group-signed events are not yet published, so a group's schema has not been where a suggester could find it, and its queue is empty until it has. The screen reads the coordinate `31889:<group>:<d>` all the same and shows whatever a relay holds for it; the suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at the reading. 32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion and canonical entry against the NIP's schema: the reading by field, the minimum suggestion, the source pointer and its absence, curation not being delegated, a malformed source, the wrong kind, the reply rules, a missing required field, the repeat rule both ways, every value rule the doc lists, `require-any`, the closed and private lists, `checkValue` on durations, patterns and https, and the coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows: verified suggestions newest first with the broken, the foreign and the wrong-kind ones dropped, an edit collapsing to its newest version, the curated mark placed by the group's canonical entry and not a stranger's, and the naming of a suggester with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest` (6) covers the relay order and the read-only filter, one request per relay carrying both queries and the watch covering both kinds, the three outcomes of `withQueue`, an `initiate` run end to end against fakes -- the relays asked, the filter watched, the rows shown, the looking ended -- and a missing schema being an error rather than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header, the row's glance line with the link kept off it, the mark on the right row and only that row, looking against empty, and the sheet with its fields, its link and its event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the button under each card in turn and in front of a member with no share. 1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@1e52fc8f25e29017f1acb72697166ecdfe99f320
2026-09-12 12:54:34 +02:00
# Films, as suggested to a curated list in the queue screen's preview.
'The Rise and Rise of Bitcoin', 'Magic Money',
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
}
def offenders():
found = []
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
# The catalogue, first. When phase 4 moved 364 strings out of composables it moved
# them out of this checker's reach at the same time -- it scanned .kt files only, so
# it went on reporting zero while "%1$s Key Packages" and three surviving mentions of
# the old product name sat in strings.xml. Externalising narrows what a source scan
# can see; the check has to follow.
if os.path.exists(CATALOGUE):
for m in re.finditer(r'<string name="([^"]+)">([^<]*)</string>',
open(CATALOGUE, encoding='utf-8').read()):
phrase = m.group(2)
if phrase in SAMPLE:
continue
words = [w for w in re.sub(r'%\d+\$s', '', phrase).split() if w]
if len(words) < 2 or not words[0][:1].isupper():
continue
later = [w for w in words[1:] if w.lower() not in SMALL]
if not later:
continue
if all(w[:1].isupper() for w in later) and \
any(w[:1].isupper() and w[1:].islower() for w in later):
found.append((CATALOGUE, m.group(1), phrase))
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
for root, _, files in os.walk(UI):
for f in sorted(files):
if not f.endswith('.kt'):
continue
path = os.path.join(root, f)
text = open(path, encoding='utf-8').read()
for m in re.finditer(r'"([^"$\\]{4,90})"', text):
phrase = m.group(1)
if phrase in SAMPLE:
continue
line_start = text.rfind('\n', 0, m.start()) + 1
line = text[line_start:text.find('\n', m.start())]
if line.lstrip().startswith(('//', '*', '/*')):
continue
# A log line is not UI copy. `logger.d("Queried Sync")` is written for
# whoever is reading logcat, and sentence-casing it would be cargo cult.
if re.search(r'\blogger\s*\.\s*[dewiv]\s*\(', line):
continue
words = phrase.split()
if len(words) < 2 or not words[0][:1].isupper():
continue
later = [w for w in words[1:] if w.lower() not in SMALL]
if not later:
continue
# Title case: every significant word capitalised, and at least one of
# them an ordinary capitalised word rather than an acronym like NIP.
if all(w[:1].isupper() for w in later) and \
any(w[:1].isupper() and w[1:].islower() for w in later):
found.append((path, text[:m.start()].count('\n') + 1, phrase))
return found
def main():
found = offenders()
print(f' {"Title Case in UI strings":42s} {len(found):6d}')
if '--list' in sys.argv:
for path, line, phrase in found:
print(f' {path.replace(UI + "/", "")}:{line} {phrase!r}')
return 1 if found else 0
if __name__ == '__main__':
sys.exit(main())