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

100 lines
4.4 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',
}
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())