Skip to content

a11y

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. ↩

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?