Skip to content

Give Extopy a visual direction of its own - #14

Merged
nathanfallet merged 4 commits into
mainfrom
design/soft-direction
Aug 1, 2026
Merged

Give Extopy a visual direction of its own#14
nathanfallet merged 4 commits into
mainfrom
design/soft-direction

Conversation

@nathanfallet

Copy link
Copy Markdown
Member

Extopy was Material 3 with a red swapped in: stock components, one type style for everything, and
a palette that was iOS system greys on a Material scheme. This gives it a direction of its own.

#D9534F is fixed by the project and stays exactly as it is. What changed is how much room it
gets: it tints every neutral in the app, and only saturates on the current tab and a liked heart.

The theme

Three files, the way nutrimaxing-app has it.

Color.kt builds both schemes around the red — no grey in here is neutral, which is what stops a
mostly-monochrome feed from looking like a system dialog. Type.kt gives the author's name, the
handle, the body and the timestamp four distinct styles; they all shared the default body style
before, which is why a post read as one flat block of text. Theme.kt assembles those with a
softer shape scale, and carries the two colours Material has no role for — a liked heart and a
repost are states of a post, not "primary" or "error".

liked is deliberately not the brand red. It used to be exactly primaryColor, so a liked
heart was indistinguishable from the accent. repostedColor was #008000, a pure green belonging
to no palette; it is now desaturated to sit beside the red.

Components of our own

Three stock components are replaced rather than themed, because theming them was not enough:

  • NavigationBar → a pill floating clear of the screen edge, current tab filled rather than
    carrying a small indicator behind its glyph. It respects the navigation-bar inset, which the old
    one did not — it sat under the gesture bar.
  • OutlinedTextField → a filled pill on BasicTextField. The stock field brings a container,
    a floating label slot, an indicator line and a 56 dp minimum height, all of which fight a
    rounded shape. Used by search and by the composer.
  • The post counters → bubbles grouped on the left, instead of icons spread across the whole
    card by SpaceEvenly, which made them read as a detached toolbar rather than as metadata of the
    text above them.

The fourteen hand-maintained icon XMLs are gone, replaced by Phosphor, which brings outline and
filled weights for everything. Only three of the old ones came in pairs, and one was not referenced
at all.

Tabs are no longer destinations

The four tabs were navigation destinations, so switching between them ran a navigation transition
across the whole screen — for a move that goes nowhere. They are now state inside MainScreen,
held in a rememberSaveable.

That also settles where the tab bar belongs: inside MainScreen. A post, a profile or the composer
are opened on top of a tab, so they simply do not have one — there is no visibility rule to get
wrong.

Which makes a back affordance mandatory rather than nice to have: those three screens had a
CenterAlignedTopAppBar with no navigation icon at all, so without the bar there was no way back
on Android
, which has no edge swipe. ExtopyTopBar gives them one.

The bar also stopped guessing which tab is current. It used to compare
route.lowercase().startsWith(item.name.replace("_", "")) — a string comparison between an enum
name and a route class name, which renaming a screen would have broken in silence.

Bugs found on the way

Not design, but they were in the screens being touched:

  • "Follow" and "Following" had their styles the wrong way round: the filled button was on the
    already following state, so following someone made the button louder rather than quieter.
  • AccountView printed the display name twice — the second line was meant to be the handle.
  • A missing biography, and a repost with no body, each left an empty line that looked like a
    rendering fault.
  • Settings headed an Accounts section that had nothing in it.
  • Direct messages and notifications rendered nothing at all. They now say they are coming — an
    empty screen reads as a failure, not as an unfinished feature. So does an empty timeline, which
    used to be a black screen.

Localisation

Following LOCALIZATION.md in nutrimaxing-docs turned up two real gaps:

  • CFBundleLocalizations was missing from the Info.plist, so iOS treated the app as
    English-only: no Language entry in Settings, and one language on the App Store listing, for six
    translations actually shipped. knownRegions was already right, which is what made it easy to
    miss.
  • No locales_config.xml and no android:localeConfig, so the per-app language picker Android
    13+ offers never appeared.

Also two escaped apostrophes in the French strings: Compose does not unescape \', so
"Nom d'utilisateur" rendered with the backslash visible.

New strings are translated in en, fr, de and es; bs, hr and sr fall back to English until someone
who speaks them writes the sentences. The three profile figures had their labels derived from the
existing translations rather than retranslated.

Verified

Android debug and release, the 17 tests, both iOS frameworks, and the app running on the simulator
against a local backend — in both light and dark, which is where the two grounds were tuned.

Not verified: navigation by hand. Tapping the simulator needs an accessibility permission the
terminal does not have, so switching tabs, opening a post and coming back are checked by reading
the code, not by using it. Worth thirty seconds of your thumb before merging. Android runtime is
still unverified too — the local AVDs have no system image installed.

Still open, and untouched here

TimelineViewModel._users is never populated, so the UserCard this PR restyles never actually
appears in the timeline. And LogoutUseCase still has no entry point in Settings. Both predate
this branch.

Note that merging runs the deploy jobs again; they currently fail on their own (missing Ruby on the
runner, and the fastlane match SSH key), so nothing ships either way.

The app ships six translations and neither platform advertised them.

`CFBundleLocalizations` was missing from the Info.plist, so iOS treated the
app as English-only: no Language entry in Settings, and a single language on
the App Store listing. `knownRegions` in the project file was already right,
which is what made this easy to miss.

Android had no `locales_config.xml` and no `android:localeConfig`, so the
per-app language picker Android 13+ offers never appeared. Following the
system language already worked; choosing per-app did not.

Also fixes two escaped apostrophes in the French strings. Compose does not
unescape `\'`, so "Nom d\'utilisateur" rendered with the backslash visible.

The greeting and empty-state strings the timeline now needs are translated in
en, fr, de and es; bs, hr and sr fall back to English until someone who speaks
them writes the sentences.
The brand red is fixed, so what this direction spends is not the hue but the
room given to it: it tints every neutral in the app, and only saturates on the
current tab and a liked heart.

The theme is now three files, the way nutrimaxing-app has it. `Color.kt`
builds both schemes around that red — no grey in here is neutral, which is
what stops a mostly-monochrome feed from looking like a system dialog.
`Type.kt` gives the author's name, the handle, the body and the timestamp four
distinct styles; they all shared the default body style before, which is why a
post read as one flat block. `Theme.kt` assembles those with a softer shape
scale, and carries the two colours Material has no role for — a liked heart
and a repost are states of a post, not "primary" or "error". `liked` is
deliberately not the brand red: a heart lighting up in exactly the accent
colour is indistinguishable from the accent.

Three stock components are replaced rather than themed:

- `NavigationBar` becomes a pill floating clear of the screen edge, with the
  current tab filled rather than carrying a small indicator behind its glyph.
  It respects the navigation-bar inset, so it no longer sits under the gesture
  bar. The tab for direct messages says "Messages": "Direct Message" is the
  title of the screen but does not fit a quarter of the bar, which is what
  used to wrap it onto two lines.
- `OutlinedTextField` becomes a filled pill on `BasicTextField`. The stock one
  brings a container, a floating label slot, an indicator line and a 56 dp
  minimum height, all of which fight a rounded shape.
- The counters become bubbles grouped on the left instead of icons spread
  across the whole card, so they read as actions belonging to the post above
  them rather than as a detached toolbar.

The tab bar also stops guessing. It used to match the current tab by comparing
`route.lowercase().startsWith(item.name.replace("_", ""))` — a string
comparison between an enum name and a route class name, which renaming a
screen would have broken in silence. It now asks the destination directly with
`hasRoute`, and raises an event instead of navigating itself.

The fourteen hand-maintained icon XMLs are gone, replaced by Phosphor, which
brings outline and filled weights for everything. Only three of the old ones
came in pairs, and one was not referenced at all.

Finally, an empty timeline says so. It used to render a black screen, which is
indistinguishable from a screen that failed to load.
…bs work

The tabs were four navigation destinations, so switching between them ran a
navigation transition across the whole screen — for a move that goes nowhere.
They are now state inside `MainScreen`, the way nutrimaxing-app has it, held in
a `rememberSaveable`. Changing tab redraws the content and nothing else.

That also answers where the bar belongs: inside `MainScreen`. A post, a
profile or the composer are opened *on top of* a tab, so they simply do not
have one — no visibility rule to get wrong.

Which makes a back affordance mandatory rather than nice to have: those three
screens had a `CenterAlignedTopAppBar` with no navigation icon at all, and
without the bar there was no way back on Android, which has no edge swipe.
`ExtopyTopBar` gives them one.

The rest is the direction applied where it had not been yet — flat cards,
grouped counters, pill buttons, the type scale:

- `UserCard` loses its elevation, and its biography line no longer leaves a
  gap when there is no biography.
- A profile's three figures stack the number over its label in two different
  styles, instead of one string split by a newline where both halves looked
  the same.
- "Follow" is the filled button and "Following" the outlined one — the filled
  state used to be on the wrong one, so following someone made the button
  louder rather than quieter.
- `AccountView` printed the display name twice; the second line was meant to
  be the handle.
- Settings groups its rows into surfaces, and drops the Accounts heading when
  there are no accounts rather than heading an empty list.
- The composer gets the same rounded field as search, and a pill send button.

Direct messages and notifications say they are coming instead of rendering
nothing: an empty screen reads as a failure, not as an unfinished feature.
The last screen still on stock styling: the headline and description now sit
on the type scale instead of on titleLarge and the default body, and the
button is a pill like every other primary action in the app.
@nathanfallet
nathanfallet merged commit dd51ab2 into main Aug 1, 2026
1 check passed
@nathanfallet
nathanfallet deleted the design/soft-direction branch August 1, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant