Skip to content

ux

Labor Limae: When "Mostly Working" isn't Enough

In his Ars Poetica, Horace uses the term labor limae to define the process by which artists refine their work before publication as if progressively smoothing down sharp edges using a file.

Without any pretension to compare myself to the great Latin poet, I kind of resonate with the filing work he describes, as I recently found several rough edges to be polished in my code, even though I invested a lot of effort to modernize the project and keep it in a healthy state (see last month's article).

Questionable Architecture

For example, I was recently scanning the codebase to find occurrences of position-based destructuring to migrate to new name-based syntax. While doing so, I found a snippet to handle translations duplicated several times, instead of creating a dedicated use case and invoke it.

In this category, it is worth mentioning the undisciplined use of Compose's composition local mechanism accumulated over the years. For each of the custom ones, I even had to add an exclusion rule for Spotless in build configuration. And even for library ones, their usage was sometimes weird, e.g. LocalDensity (to set custom font scale) was provided in three different places with the same exact source.

Another example was that post actions (the ability to reply, share, bookmark, etc.) were exposed through a repository which was accessed directly from the UI layer. A more solid solution would have been to save permissions in the UI model and populate them in the presentation or, even better, pagination logic. The funny thing was that I had already handled custom emojis in the "correct" way, so apart from a suboptimal architectural choice this was also an inconsistency.

Code Inconsistencies

Speaking of inconsistencies, there were reducer functions in ViewModels, where sometimes intent was handled in place, sometimes it was delegated to a private handler method whose naming did not follow a consistent pattern. Moreover, some of the handlers were suspend functions, while others encapsulated asynchronicity inside the function body.

Another inconsistency I found was in assisted injection: sometimes assisted factories were considered an implementation detail and extended a parent interface, sometimes they were exposed directly to call sites. Considering the flexibility provided by the former pattern, I chose to consistently adopt it everywhere: consider the assisted factory an implementation detail and bind it to the public parent interface for clients.

Poor Naming Choices

In computer science there are just two difficult things: cache invalidation, naming things… and off-by-1 errors (sic)!

Raccoon is no exception: there were some confusing legacy naming choices. Suffice it to say that "paged lists" were called "lists with page cursor" and "posts" were called "timeline entries".

The reason why I chose to do so was, in the early days, to not adopt any backend-driven terminology (e.g. Mastodon defines them as "statuses", Friendica calls them "items", etc.). However, my initial choice was verbose and ultimately did not age well: it was too vague to convey any meaning and too verbose.

Overcomplication

In all cases where assisted injection was used were only allowed a single assisted param, using a wrapper data class where multiple values were needed. This was a legacy of Voyager's Koin integration, which somehow survived across the years, even if navigation was migrated first to Compose Navigation and then to Navigation 3; while dependency injection switched to Koin + KSP annotations, Kodein, Kotlin + compiler plugin and finally to Metro. There was no longer any need to wrap values inside a data class and have a single assisted param.

Concerning navigation, I had another "WTF moment" when I realized that my abstractions1 were beautifully architected with interface-implementation and DI annotations, but the concrete class was manually instantiated where needed. And for a good reason, injection would have never worked since there were constructor parameters which couldn't be pulled off Metro's graph as there were no bindings for them (the correct approach was to pass them via assisted injection).

Lack of optimization

Knowing that obfuscation, minification and resource shrinking, if not properly configured, could lead to breakages in release builds, I left those optimizations disabled at the beginning of the development stage. I thought "I'll enable them when I start publishing release builds", then time passed, I started distributing to hundreds of users, and the options remained disabled.

It was not until Google Play warned me that the app could be delisted for not turning on advanced protection that I found the time to turn them on, test and debug, and ship the optimized bundle. I didn't even realize that the APK size, for distribution outside Google Play, had increased since the adoption of Metro, which could be an issue for both third-party store maintainers and users who had to download larger archives.

Considering app size, how about redundant resources? I found I had two variants of the "play" arrow for videos, one with and one without an outlined circle. They are simple vector images, so do not add that much to the final bundle size but, again, isn't it better to have just one?

UX & a11y Pitfalls

Finally, there was a whole list of small interventions for things that got on my nerves every time I used the app.

The search screen was opened with the keyboard opened by design. Ok, but it remained opened when scrolling the lazy list, which felt weird and kept the focus away from the search results. Moreover, worse still, when navigating to another screen (e.g. post detail) and going back to the search, the keyboard reopened. This, again, felt weird and made the search feature feel clunky.

Then, the image detail screen. I designed the feature to handle multiple elements (with a horizontal pager) and multiple media types (images and videos). This was cool, but the image viewer also had a pan-to-zoom behavior, which conflicted with the page scroll gestures. The solution I came up with two years ago, when my only goal was to complete the MVP, was to lock the zoom initially, and change the zoom level with a double tap. Again, this felt unnatural, but it wasn't until last week that I designed a better gesture hierarchy, passing scroll events to the pager unless zoomed in beyond 1x.

Still concerning images, Compose Multiplatform added shared element transitions from version 1.7.0 in October 2024. We're in October 2026 and there was still no use of it in Raccoon, because there was always something more important to do. Well, users deserve better! Version 1.1.7 adds shared element transitions when opening single images from post lists, post detail and user detail.

This was also an occasion to spot all the cases where Modifier.clickable was added to UI components with an empty lambda, which resulted in extraneous ripple effects and accessibility tools incorrectly identifying non-interactive elements as clickable.

And, finally, plain old bugs. For example, the reblog count was incorrect after adding or removing a boost, the navigation drawer could lock in the closed state and never reopen, the list-detail navigation was behaving incorrectly in some paths, predictive back did not always animate correctly on mobile.

Wrapping up

It's easy to let legacy code pile up when things work in "most cases". But true project health isn't always about shipping new features: it's also about the mindset to go back, smooth down rough edges and reconsider previous choices if the experience suggests so.

Refactoring, eliminating bugs and inconsistencies, and tightening up gestures and a11y isn't just a technical exercise. It translates into a faster, smoother, and more user-friendly experience for everyone.

Version 1.1.8 is the result of this "filing" work: lighter binaries, better navigation transitions, intuitive media interactions and a cleaner foundation ready for whatever comes next.

Tip

Labor limae is never really finished, but every pass brings us closer to the polished experience Raccoon users deserve.


  1. Which were used to encapsulate Voyager's Navigator first, then Compose Navigation's NavController and finally Navigation 3's NavBackStack. ↩

The Devil in the Details: Polishing Raccoon's UX

As you may remember from previous articles (here and here), the way Raccoon displays group accounts is a bit special.

Forum Mode: simple by design

Instead of opening them in Classic mode like regular accounts (with four sections: Posts, Posts & Replies, Pinned, Media), groups are displayed in Forum mode with a single section.

This layout1 has the purpose of giving prominence to the "aggregator-like" feature of Friendica, and the screen structure is intentionally simple.

The screen title displays the group name, while the main content is a refreshable list of top-level posts, each of which is the root topic of a discussion thread. Full stop.

Spotting the missing bit

In my main Friendica instance, I have a group account which I use to communicate news and receive feedback. I check it out from time to time to see whether there are bug reports or feature requests.

I don't know how it could have happened… probably because I've always had more important aspects to focus on, but it was not until very recently that I realized Forum mode was missing a crucial bit.

There are basically two ways a user can interact with a group:

  1. Participating in the discussion by either:
    • creating a new thread, which translates to creating a new top-level post mentioning the group;2
    • adding a reply to an existing thread;
  2. Joining the group, which translates to following the account.

In Raccoon, the first kind of interaction had been implemented, documented and optimized. But what about the second one?

No button, no dedicated item in the top app bar actions menu, nothing. Absolutely nothing.

The only way to actually join the group was (ironically) to switch back to Classic mode and use the relationship button which is shown for regular accounts.

Small details, big impact

In order to solve this quite important usability flaw, I chose to implement a header card, very similar to the one used for regular users, except that it is vertically more compact3 to keep the focus on the discussion.

It is weird that I am only realizing this after almost two years, of project development.

However, since groups are a minority of accounts in the current Fediverse, this oversight could go unnoticed.

The telltale sign was a user comment asking to make Classic mode the default, which got me wondering why: what could make a user miss the classic view?

This is part of a continuous effort to make the UI more functional and appealing. I've also recently restyled the app info dialog to have a "fresher" layout (more concise texts, custom logos, etc.).

I am also trying to make interactions with groups easier, literally "a touch of a finger away" for everyone, because it is one of the most powerful and interesting features of the Fediverse.


  1. Which can be disabled in Settings by turning off "Open groups in forum mode by default". ↩

  2. With either an @-style public mention (Mastodon & Friendica) or a !-style private mention (Friendica-only). ↩

  3. The possibility to enable/disable notifications was placed in the top app bar action menu. ↩

Beyond contentDescription: Accessibility in Compose Multiplatform

Accessibility in a nutshell

Let's start with a quick recap about definitions and general principles. a11y is the practice of designing an environment so that in can be used equally by everyone, regardless of their abilities.

Applied to digital platforms, this boils down to the POUR principles, i.e. the foundational principles identified by the WCAG:

  • Perceivable: information and UI components must be presented to users in a way they can perceive (e.g. images must have alternative text, clickable controls are apparent and their purpose is clear, etc.);
  • Operable: components must be usable and functionalities can be accessed via different inputs (e.g. via touch input or voice commands) without barriers;
  • Understandable: the operation of the UI must be readable, predictable and designed in a way to help users avoid mistakes or guide users to correct them;
  • Robust: content must be reliable to be interpreted by a variety of assistive technologies and resilient to still be usable as they evolve over time.

What Compose offers out-of-the-box

In an application like Raccoon, the simplest and most idiomatic way to create a UI is leveraging the org.jetbrains.compose.material3:material3 library which provides Compose Material 3 components for CMP, i.e. the Material 3 design system by Google.

This has a lot of advantages, it provides a unified "language" to define User Interfaces which follow well-established visual and behavioral patterns by design and from the ground-up. The library is extremely flexible: it provides "ready-made" versions for common UI elements (with several "extension slots" to adapt them to match the target look and feel). Moreover, since it is built upon theorg.jetbrains.compose.foundation:foundation library, when needed you have access to Compose foundational layer to create the custom elements you need. The MD system also comes with a standard library of symbols that have the role of a "visual dictionary" for icons to be used in front-ends.

With respect to a11y, Material 3 components and foundational components themselves have bult-in support for accessibility: they are designed to be recognizable, usable, multi-input and interoperable with assistive technologies (e.g. TalkBack on Android). Most of the time, they work "out of the box" with minimal interventions needed.

Just to make an example, buttons already come with semantics that make them recognizable as interactive components and their default layout has built-in elements (e.g. padding) which make them follow the guidelines for human interaction. If a button's only content were an icon, nonetheless, developers are encouraged to provide a description of the function of the button when the role of the symbol is not clear in its context.

Tip

In general, whenever a graphic element (image or video) is inserted with no textual equivalent, developers need to insert a content description to meet the aforementioned Perceivable requirement.

More specific interventions highly depend on the particular domain the application belongs to.

What we Actually Did

UX for Screen Readers

Raccoon, for example, is client for a social network. This implies that the type of content which is presented most frequently is the "feed": e.g. some kind of timeline with a sequence of posts, the sequence of answers to a given post, the list of posts created by a given user, etc.

In the UI representing a timeline, each post in a timeline has multiple interactive elements (avatar, author name, reply, reblog, favorite, bookmark, options). A screen reader user would have to swipe a dozen times just to get past a single post, which makes scrolling through a timeline cumbersome.

In order to overcome this issue, the main interventions have been:

  • Hiding Granularity: use Modifier.clearAndSetSemantics { } on individual buttons in the footer and header, so that they are removed from the primary focus loop.
  • Merging Descendants: apply Modifier.semantics(mergeDescendants = true) to the entire TimelineItem and TimelineReplyItem, so that the whole post is focused as one single unit.
  • Custom Actions: re-implement the hidden buttons as CustomAccessibilityActions attached to the main post container.

As a result, users can navigate from post to post with a single swipe. If they want to interact, they use the "Actions" gesture of their screen reader to select "Reply", "Favorite", etc.

Danger

A couple of caveats to avoid common pitfalls:

  • mergeDescendants can sometimes hide too much if not used carefully:
  • labels for CustomAccessibilityActions must be localized to remain truly accessible.

Key commits: 1aa17ea and 0b7adf1

Content Parity

In a federated environment like Mastodon or Friendica, content often arrives as raw HTML. Embedded images (inline elements) within post text often had important alt descriptions.

It is important that the value of this attribute is parsed (using Ksoup as a parser), passed up and used either ascontentDescription for Image composables or as alternateText for inline contents insideBasicText composables.

In this way, screen readers can now read the descriptions of images, both when they appear as media attachments and when they are embedded directly in the flow of a post.

On the other hand, nonetheless, too many contentDescription can also create too much noise: it is important to distinguish decorative VS functional items and annotate just the latter. So, as a part of the validation process, a systematic cleanup of alternate text properties was done.

Key commits: d2fc4dd

Semantic Integrity

A Switch or Checkbox next to a Text composable often results in two separate focus points, which is confusing and inefficient.

The solution to this is using Modifier.toggleable with Role.Checkbox or Role.Switch on the parent Row container, nullifying the onCheckedChange callback to avoid double-handling.

By doing so, the entire row is treated as a single interactive control: when focused, the screen reader announces the label and the state together.

Moreover, large lists like timelines or settings screens are hard to navigate if you can't jump to specific sections.

The solution is to apply Modifier.semantics { heading() } to: - post titles in the Timeline; - headers in the Settings screens.

In this way, screen reader users can change their navigation mode to "Headings" and jump directly from post to post or section to section, skipping the body text entirely if desired.

Key commits: 8ab2772

Community is Key

The overall purpose of dealing with a11y is inclusion, which means that nobody is alone.

Tasks can be overwhelming to tackle all by oneself, but in an inclusive community you can always rely on the support by others.

You may have noticed that the commits liked at the end of the previous paragraphs were not done by a single person.

This is because for Raccoon all the interventions and fine-tuning summarized so far were implemented, tested, and validated by at least two people: pvagner on the one side and the project maintainer on the other.

Why All This Matters

Ultimately, accessibility isn't a checklist or a set of technical hurdles: it is a commitment to our users' dignity. By moving beyond simple content descriptions and thinking about the semantic flow of our apps, we ensure that the Fediverse remains a place where the 'open' adjective in FOSS applies to everyone.

And, for fellow developers, the next time you build a component, ask yourself: is this just visible, or is it truly reachable?


Create Adaptive Designs with Window Size Classes

What are Window Size Classes?

Material Design 3 has introduced the concept of Window Size Classes to help developers abstract away the overwhelming variety of device viewports. Instead of designing for multiple specific screen resolutions, we can categorize available space into three opinionated buckets: Compact, Medium, and Expanded.

According to the official documentation, window size classes provide a bridge between designer-friendly breakpoints and developer-centric implementation. They allow us to focus on how the UI should transition between different states rather than worrying about specific pixel counts.

This is especially critical in a project like Raccoon, which targets multiple platforms (Android, iOS, and desktop) where available screen real estate can change in a heartbeat.

Multiplatform Implementation

In a KMP project, querying the current window size class requires platform-specific implementations. We can leverage the expect/actual mechanism to provide a clean, unified API to our common code:

  • Platform-specific query logic: Android requires an Activity context, while iOS and JVM can query window bounds directly.
  • Shared utilities: We keep our business logic DRY by placing the observation and reaction logic in commonMain.

Code Snippets

To obtain the current window size class and react to its changes dynamically:

// in commonMain
@Composable
expect fun getWindowSizeClass(): WindowSizeClass?

// in the androidMain source set
@Composable
actual fun getWindowSizeClass(): WindowSizeClass? {
    val activity = LocalActivity.current
    checkNotNull(activity) { return null }
    return calculateWindowSizeClass(activity)
}

// in iosMain
@Composable
actual fun getWindowSizeClass(): WindowSizeClass? {
    return calculateWindowSizeClass()
}

// in jvmMain
@Composable
actual fun getWindowSizeClass(): WindowSizeClass? {
    return calculateWindowSizeClass()
}

Once we have the size class, we can create shared utilities to make our UI code more readable:

// all in the commonMain source set
@Composable
fun isWidthSizeClassEqualOrAbove(other: WindowWidthSizeClass): Boolean {
    val current = getWindowSizeClass()?.widthSizeClass ?: WindowWidthSizeClass.Compact
    return current >= other
}

@Composable
fun isWidthSizeClassBelow(other: WindowWidthSizeClass): Boolean {
    val current = getWindowSizeClass()?.widthSizeClass ?: WindowWidthSizeClass.Compact
    return current < other
}

A Strategy for Adaptive Layouts

Following established best practices, I chose the Expanded width as our primary breakpoint for structural layout changes. Here’s how the experience shifts across devices:

Compact & Medium Screens

On smaller form factors, I prioritize reachability and density:

  • Bottom Navigation: Main sections are nested here for quick thumb access.
  • Modal Navigation Drawer: Reserved for secondary features and settings.
  • Floating Action Buttons: Tucked into the traditional bottom-right corner.

Expanded Screens

When we have the luxury of space on tablets and desktops, we can reduce navigation depth significantly:

  • Permanent Navigation Drawer: Replaces the bottom bar with a collapsible side panel.
  • Top Bar Actions: Common shortcuts migrate to the top bar for better visibility.
  • Multi-Pane Scaffolds: We move beyond single columns to leverage the full width.

Interestingly, since Raccoon features more than seven primary destinations, a standard Navigation Rail (the MD3 go-to for side navigation) wasn't feasible. This pushed us toward a more custom, collapsible drawer approach that maintains usability without clutter.

Leveraging Canonical Layouts

A cornerstone of Material Design 3 is the use of Canonical Layouts. These are battle-tested patterns—like List-Detail, Supporting Pane, and Feed—that provide a rock-solid foundation for adaptive applications.

In practice, this means reaching for specialized components like ListDetailPaneScaffold or SupportingPaneScaffold. When paired with sub-navigators like ThreePaneScaffoldNavigator, they unlock sophisticated navigation flows where "master" and "detail" views can seamlessly coexist or stack depending on available width—complete with beautiful, built-in animated transitions!

Adopting this approach often implies maintaining two distinct navigation graphs: one optimized for compact/medium screens and another for expanded layouts. A real-world example of this in Raccoon is the TimelineWithEntryDetailScreen, which dynamically reconfigures its internal structure on the fly.

screenshot of list-detail pane scaffold
A screenshot of timeline / detail two-pane scaffold.

Multiplatform and Beyond

This effort went hand-in-hand with adding support for the JVM target for the desktop app. By adopting window size classes, adding a completely new platform was remarkably smooth. The UI simply "snapped" into place once the desktop window bounds were mapped to the correct size classes.

Looking Ahead: Navigation 3

While the current approach of maintaining separate navigation graphs works well, the future of adaptive navigation in Compose looks even more promising. The upcoming Navigation 3 library introduces the concept of Scenes and Scene Strategies.

These allow developers to define how a destination should be displayed (e.g., as a full-screen pane, a detail pane, or even a bottom sheet) based on the current window size class, all without having to duplicate destination logic. It effectively abstracts the "where" and "how" of navigation away from the "what".

As of now, Navigation 3 is still in its early stages and might not be mature enough for production-heavy apps like ours. So I've decided to wait until the library stabilizes further, but the migration path is definitely on my radar.

Question

Who knows? Maybe future update could see Raccoon powered by these new scene-based strategies!

Swipe Navigation: turn your Fediverse feed into a page-turner

Question

Tired of the endless tap-back-tap dance? There's a better way to browse!

Picture this: You're deep-diving into your Mastodon timeline when you spot an intriguing post. You tap to read the full discussion, scan through replies, then… tap back. Find your place again. Tap the next post. Repeat. Does it sound familiar?

What if scrolling through social content felt as natural as flipping through a magazine? Enter swipe navigation – the game-changing UX pattern that's transforming how we consume Fediverse content.

Problem: lost in navigation hell

Every Fediverse platform has its building blocks – Lemmy's posts, Mastodon/GNU social's statuses, Friendica's items – but they all share the same fundamental challenge. These content units live in lists: timelines, author feeds, community threads, search results. And traditionally, exploring them means constant context switching.

The typical mobile experience goes like this:

  1. Scroll through feed
  2. Tap interesting content
  3. Read, engage, absorb
  4. Hit back (losing your flow)
  5. Hunt for where you left off
  6. Repeat until frustrated

This master-detail navigation works on large screens, but on mobile? It's a focus-killer and engagement-destroyer.

Solution: think of a book, not a db

Swipe navigation flips the script – literally. Instead of treating content as isolated database entries, it transforms your feed into a flowing narrative where each post is simply the next page in your story.

Swipe right: Previous content
Swipe left: Next content
Stay engaged: Never lose your place

It's that simple. No more navigation gymnastics, no more losing your scroll position, no more breaking your reading flow.

The technical behind the scenes

Making this feel effortless requires some clever engineering. Here's how we solved it in the Raccoon apps:

Smart pagination memory

We maintain a "snapshot" of your current browsing context:

  • current pagination specification i.e. all the data which are needed, if you have page n, to get page n+1;
  • partial list of all the contents which have been downloaded so far;
  • current pagination status (e.g. the current page index or the next "pagination token", the information about whether there are more pages or not).

Intelligent prefetching

As you swipe toward the end of loaded content, the app quietly fetches the next batch in the background. You get the illusion of infinite content without wait times, data waste or battery draining, because only as many contents are downloaded as they are likely to be seen.

Context navigation stack

Here's where things get interesting. What happens when you're browsing your home timeline, then dive into someone's profile, then start swiping through their posts?

We solved this with a navigation stack – think of it as breadcrumbs for your browsing session. Each time you enter a new feed context, we push a new state onto the stack. When you navigate back, we pop it off and restore exactly where you were in the previous feed.

Example flow:

  1. Browsing home timeline (State A)
  2. Open user profile → New context (State B pushed)
  3. Swipe through their posts using State B pagination
  4. Hit back → Pop State B, restore State A
  5. Continue exactly where you left off in home timeline

Why this matters for the Fediverse

This isn't just about smoother UX – it's about engagement and adoption. The Fediverse is competing with highly polished corporate platforms that have spent billions optimizing user experience.

Swipe navigation levels the playing field by:

  • Reducing friction: fewer taps, less cognitive load
  • Maintaining flow state: users stay immersed in content
  • Feeling native: matches expected mobile interaction patterns
  • Encouraging exploration: easier to discover new content and creators

The Relay legacy

Fun fact: This navigation pattern gained popularity in the Reddit ecosystem through the Relay app, which is why some developers still call it "Relay-style navigation." It proved so effective that it became a sought-after feature across Threadiverse clients, and now it's making its way into broader Fediverse apps.

Building a better social web

Every interaction pattern we choose shapes how people experience the open social web. Swipe navigation might seem like a small UX detail, but it represents something bigger: the commitment to making decentralized platforms not just functional, but delightful.

When users can lose themselves in their feeds – really get into that flow state where time disappears and content discovery feels effortless – that's when the Fediverse truly competes with the walled gardens.

The future of social media is open, decentralized, and user-controlled. But it also needs to feel amazing to use. Swipe navigation is one piece of that puzzle, turning mechanical browsing into intuitive exploration.

Ready to transform your Fediverse experience? Look for apps that support swipe navigation, or if you're a developer, consider implementing this pattern in your next project. Your users' engagement levels will thank you.

Question

What navigation patterns do you think could improve the Fediverse experience? Share your thoughts and help us build better social tools for everyone.