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>
This commit is contained in:
@@ -29,7 +29,7 @@ BUDGET_SPACING_LITERALS=0 # phase 2: reached 2026-09-08
|
||||
BUDGET_BARE_CLICKABLE=0 # phase 3: reached 2026-09-08
|
||||
BUDGET_NULL_DESCRIPTION=0 # phase 3: reached 2026-09-08
|
||||
BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10
|
||||
BUDGET_TITLE_CASE=-1 # phase 4 drives to 0
|
||||
BUDGET_TITLE_CASE=0 # phase 4: reached 2026-09-08
|
||||
BUDGET_UNSET_COLOR_ROLES=0 # phase 1: reached 2026-09-07
|
||||
|
||||
fail_count=0
|
||||
@@ -193,9 +193,13 @@ literals=$(( $(count 'text = "') + $(count 'Text\("') ))
|
||||
report 'string literals in composables' "$literals" "$BUDGET_STRING_LITERALS"
|
||||
res=$(count 'stringResource|Res\.string')
|
||||
note "stringResource / Res.string: $res"
|
||||
title_case=$(grep -rhoE '"[A-Z][a-z]+( [A-Z][a-z]+)+"' "$UI" --include=*.kt 2>/dev/null | sort -u | wc -l | tr -d ' ')
|
||||
report 'distinct Title Case strings' "$title_case" "$BUDGET_TITLE_CASE"
|
||||
note 'includes preview sample data (person names); phase 4 triages'
|
||||
# Delegated, because the grep version was wrong twice: it required every word after the
|
||||
# first to be capitalised (missing "Invite a Friend") and it read one line at a time
|
||||
# (missing a `Text(` whose literal was on the next). It also counted sample data --
|
||||
# "Steve Biko" is title case because that is how a name is written.
|
||||
title_case=$(python3 docs/scripts/m3-title-case.py | grep -oE '[0-9]+$')
|
||||
report 'Title Case in UI strings' "$title_case" "$BUDGET_TITLE_CASE"
|
||||
note 'run docs/scripts/m3-title-case.py --list to see them'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
hdr 'States and feedback (phase 5)'
|
||||
|
||||
78
docs/scripts/m3-title-case.py
Executable file
78
docs/scripts/m3-title-case.py
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/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'
|
||||
|
||||
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 = []
|
||||
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())
|
||||
Reference in New Issue
Block a user