The Fediverse is built on the promise of decentralized, equal communication. However, when users
from different instances federate, they often encounter a barrier that code alone can not fix:
language. While global connectivity is built into the protocol, making sense of foreign content
often requires external tools that many servers do not provide.
Services like Mastodon are designed to integrate with translation providers, so that users can
toggle on-demand translation for posts appearing in their timelines.
This integration is not automatic, though, it has to be explicitly configured by instance
administrators:
if requests are routed to an external provider (such as DeepL or a
LibreTranslate instance), admins have to configure an API key and
pay the costs for translations;
alternatively, since LibreTranslate is a self-hostable FOSS appliance, it can be
installed on a server they already own (even the same hosting the instance), but they still need
to pay for used resources.
As a result, not many instances (especially smaller ones) offer server-side translation, as they
are run by volunteers who already run instances at their own expenses, often on a not-so-generous
budget.
A growing user base already means more storage and computational resources are needed just for
content management, so it is no surprise that translation is an expense worth sacrificing.
A possible solution to this is managing translation on the client-side. This doesn't necessarily
mean running a local AI model on-device; while privacy-preserving, the large download size and high
memory demands can be a dealbreaker for many mobile users.
A more flexible approach is allowing users to connect to their preferred translation service
directly. By sending requests to a user-configured LibreTranslate instance and using
their own API key, the cost and quota management are decentralized.
This removes the financial burden from instance admins while giving users control over their data.
They can choose their trusted service and have their saying in whom data is sent to.
This is the path may apps have taken, including Raccoon. In the settings screen it is possible to
configure one or more translation providers and choose the default one.
Translation provider configuration bottom sheet.
For now the only supported provider is LibreTranslate: its configuration require the user to enter
the instance URL and their API key (most instances require one).
Once a translation provider has been selected, the options menu in each post whose language
is not the current one,1 contains an option to toggle translation. When selected, the original
content is swapped with the translation and the drop-down menu option allows to switch back to the
source version.
Getting this right was not an overnight process. Before integrating with LibreTranslate I
experimented with another third-party service (see
#746) but the output was
very low quality. Users complained, I listened to their feedback and decided to remove it.
The new architecture implemented in
#1176 is built on two
core principles: user choice and extensibility.
It is flexible enough to let users configure more than one service and extensible enough so
developers can add other implementations for different providers (local or remote).
Question
How do you currently handle posts in languages you don't speak? Do you rely on your instance’s
built-in tools, or do you find yourself copy-pasting into external browser?
Let's talk about how we can make the Fediverse feel more like a global neighborhood.
The current language corresponds to the app's on (from Settings), the
post's one corresponds to its language property. ↩
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.
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.
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.
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.
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.
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.
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?
Raccoon apps are Gradle projects, meaning that all tasks (compilation, verification, packaging) are
orchestrated by a tool called Gradle. In this article we will explore the
relationships between build system and project structure, discuss why some architectural choices
are better and how to configure builds so that the tool works for you (and not vice versa).
Gradle is a general-purpose tool for projects written in Java, C++, Scala, JavaScript, Groovy and
Kotlin or any combination thereof. It is an extremely powerful tool, suited for enterprise-scale
projects (backend or full stack applications, Android apps, KMP apps and so on). It is designed for
extensibility, with a wide range or third party plugins which make it very flexible and adaptable,
at the cost of a quite steep learning curve. Setting it up correctly is just the first step, being
able to analyze build traces, identify errors and fix them indeed takes some years of seniority.
Nonetheless, Gradle is still the de facto standard for Android and KMP projects as of 2026, even
if there are alternatives. For example the Kotlin toolchain,
backed by JetBrains itself, is very promising and, personally, I foresee that in the future the
community will gravitate towards leaner and more specialized tools like that.
Info
Gradle is deeply integrated into the Android ecosystem (e.g. with AGP), so it will
likely remain the requirement for Android-specific packaging in the foreseeable future.
If Gradle is not configured properly, there are several downsides. The most obvious one is a build
failure, which can give some headaches but at least you realize immediately that something is wrong.
But there are more subtle ones: for example its performance may degrate significantly, and as the
project grows you end up wasting more time waiting for builds to finish rather than actually working
on the project.
Or, which is worse to me, your build config becomes unmaintainable. As technology evolves or plugins
are added, you end up with lots of interconnected yet sparse build scripts and config files
scattered in your source tree. Each new change may be incompatible with other ones and break
everything at the least expected moment in an endless dependency nightmare.
Now, let's move into the solution space. To control build times, the most important thing is to
avoid running unnecessary steps and caching results from already performed steps to reuse them in
subsequent builds. This is a wide topic and would require several chapters to properly discuss it.
However, the key idea is that building a project is a sequence of interconnected tasks, where the
output of each one may be the input for another one. This concept in CS is commonly referred
to as a "pipeline", and all built tools from the early days work in this way (GNU Make, Apache ant,
Maven, etc.).
Since tasks are deterministic, if all the inputs of a task are unchanged, its output will be
unchanged too so it may be skipped. So, for example, if the sources of a specific compilation unit
are not changed from a previous run, the corresponding binary does not need to be recreated. But
sources are not the only input of the build step of a given unit, it may also depend on the APIs of
other units and if those are changed the current one is affected too.
The key to avoid unnecessary passes is to isolate the project in discrete components with
well-defined rules about which units depend on which other ones in order to minimize coupling
and, therefore, the amounts of rebuilds whenever changes occur.
In Gradle projects, this boils down modularization strategies. Every Gradle project consists of a
root project, i.e. the container project which results in the main artifact when the build
is completed (this may be a .ear, .war, .jar, .apk or whatever package depending on the applied
plugins) and one or more subprojects whose build outputs are assembled into the main artifact. So,
the assemble task of the root projects depends on the output of the assemble task of each
subproject.
"Depending" in this sense has two negative consequences affecting build times:
the dependant task needs to wait until all its dependees have completed;
every time a dependency changes, the dependant is invalidated and needs to rerun.
Building each subproject can happen in parallel, so having more than one is usually beneficial in
modern multicore architectures. However, if there are dependencies between subprojects, the two
aforementioned consequences for each dependant apply.
If subprojects are seen as nodes and a dependency is a directed edge pointing from the dependant to
the dependee, Gradle projects have the form of a DAG, implying that all builds end
sooner or later (no cycles).
But some configurations are worse than others for performances. Let's consider the following
configuration for example:
stateDiagram-v2
[*] --> C
[*] --> D
C --> B
B --> A
Diagram illustrating the "chain" configuration.
where we see that for the build to complete C and D are needed, but C needs B to complete, and B
needs A to complete in turn. This means that A, B and C can not be run in parallel and that each
change in A affects B, C and the overall result.
Let's also consider the following scenario:
stateDiagram-v2
[*] --> B
[*] --> C
[*] --> D
B --> A
C --> A
D --> A
Diagram illustrating the "fan-out" configuration.
Here we successfully decoupled B, C and D so that they can run in parallel, which is good. But they
all depend on A so that each change in A determine that every other module is affected and must be
recompiled.
Modularizing in the correct way allows to minimize the occurrences of situations like these. There
is a consensus among mobile developers that modularization by feature is the best one to avoid
unwanted entanglements between different parts of the app, rather than isolate modules by layer.
However, each project has its needs and each developer has their opinion, so a balance needs to be
found. In Raccoon, I decided to divide projects in three kinds:
core modules: reusable pieces of software that provide the foundational layer for all
other ones. Each of them should not depend on any other module ideally, but if it really has to,
it can only be on some special core modules with the least amount of outgoing edges (e.g.
:core:di, :core:l10n or :core:preferences). They are divided logically by the functionality
they provide (except :core:utils).
domain modules: contain the model and business logic and are roughly divided by area, e.g. the
classes related to identity, the ones related to remote contents, and so on. Here the
criterion is more layer-centered than feature-centered (e.g. content-related data models are in a
single module) but for practical reasons, because application logic and domain models are not
changing so frequently in this kind of project. Domain modules can depend only on core modules.
feature modules: contain the presentation logic and they are strictly divided by feature. As
a matter of fact, there is almost a 1:1 relationship between each subproject and each app screen.
Feature modules can depend on core and domain modules, but not on any other feature.1
Splitting a project into multiple subprojects comes with some configuration overhead. Each
subproject needs its build script with its applied plugins, its dependencies, its configuration.
Some parts of the build scripts are common to all modules, some are "more equal" than others (e.g.
all modules having a UI need the Compose compiler plugin applied and include Compose dependencies),
etc.
For external library versions, there is an out-of-the-box solution already offered by Gradle:
Version Catalogs which allow to centralize in a single source of truth all the dependency
versions.
The solution I decided to adopt for common configuration is known as Convention Plugins,
i.e. isolating the repeated bits of build configuration – e.g. applying some Gradle plugins or
including some dependencies which always go together or configure a plugin in some way e.g. for
running tests.
This way it is simpler to make changes, because you have to only change the definition of the plugin
to automatically adapt dozens of subproject in a row; which was extremely beneficial in situations
like the AGP 9.x migration.
The main custom plugins I wrote are:
com.livefast.eattrash.kotlinMultiplatform to be applied in all modules to apply and configure
the org.jetbrains.kotlin.multiplatform and com.android.kotlin.multiplatform.library plugins;
com.livefast.eattrash.composeMultiplatform to be applied in all UI related modules to apply and
configure the org.jetbrains.compose and org.jetbrains.kotlin.plugin.compose plugins;
com.livefast.eattrash.test and com.livefast.eattrash.uiTest to configure host tests and device
tests.
I consider that the ability to spot and avoid pitfalls is crucial in order to maintain a high
quality of developer experience, not only user experience; and working on Raccoon one has
been a great training ground which allowed me to become a better developer for my work IRL.
Identify common parts (even in configuration, not only in logic) and factor them out to reusable
pieces of code is an application of the DRY principle; it saves a lot of time which can be dedicated
to work on more valuable activities.
Plus, it taught me not to "fear" Gradle and better understand the tools I use in my everyday life,
even at work and not only in side projects.
Question
What is your experience with Gradle? Did you come to similar solutions or do you have futher
recommendations? Let me know!
This requires some careful design for navigation, because navigation between screens must be
totally decoupled. In Raccoon I used a :core:navigation module and abstracted away the underlying
navigation system (I actually migrated from Voyager to Compose Navigation and I am willing to
migrate to Navigation 3 soon). ↩
KMP is a remarkably flexible and unintrusive technology: it allows you to choose exactly which code
to share between platforms without forcing any specific architectural patterns on you.
We've discussed its key advantages and why it's a compelling choice for building Fediverse apps in
a previous post.
However, Raccoon has always been highly Android-centric. For a long time, I targeted only one
platform, tested only on Android, and distributed only via APKs and app bundles. iOS-specific
code was written where necessary during development, but it often felt like a shot in the dark.
Occasionally I would run a debug build on an iOS simulator just to gaslight myself into believing
this was a truly multiplatform project, even when all factual evidence pointed to the contrary.
But sooner or later, the moment to face reality arrives. For me, it came when I was invited to speak
at this year's DevConf 2026 about Raccoon. I only had one issue: the app
had mostly seen bug fixes and maintenance over the last year. As I was heading toward a stable 1.0
release, I lacked "cool and shiny" new features to showcase at a conference.
Then it hit me: I could finally fulfill the requests from my Linux users and build a desktop
version. This would allow me to present not just a mobile app, but a cross-platform open-source
client for the federated social web.
I decided to dedicate a weekend or two to porting the app, promising to pivot if it didn't feel
worth the effort. To my surprise, after just one night of work, I had a macOS app running. It was
missing features and the layout was… questionable, but it worked and this was enough to give me fuel
to continue the work!
Spoiler
We eventually decided to switch topics for the conf (stay tuned!) but Raccoon will still
be making an appearance!
This boiled down to configuring the org.jetbrains.kotlin.multiplatform Gradle plugin for a new
target. Luckily, Raccoon uses convention plugins (more on this in a future post). Since I already
had acom.livefast.eattrash.kotlinMultiplatform plugin, I only had to add jvm() within a single
extension function to automatically update over 50 subprojects.
Admittedly, there's a bit more to it since I'm also using CMP. Similarly, I
updated my com.livefast.eattrash.composeMultiplatform convention plugin to adapt the UI layer
across all affected subprojects.
Every Java-based application needs an entry point. I initially created a main() function in the
:shared subproject, but later moved it to a dedicated :desktopApp module. This aligned the
project structure with the new JetBrains defaults, which we discussed in
a previous post.
Other times, it was difficult because no JVM equivalent existed for specific mobile
functionalities (like Moko-permissions
or UnifiedPush).
Even seemingly simple tasks turned out to be tougher than expected. For instance, reading app
metadata (version and build number) is intrinsically coupled with how the application is packaged
for each specific platform.
The OAuth2 login flow deserves its own spotlight. Typically, you redirect the user to an external
provider in a webview, they authenticate, and then redirect back with a code you exchange for a
token.
On mobile, this relies on deep linking (custom URI schemes) so the OS knows to pass the data back to
your app. Web apps just point back to a backend server.
But what about desktop apps? I initially tried using
the Calf webview, but it struggled with intercepting the
redirects needed for the OAuth2 flow.
The solution? I leveraged Ktor — already in use for network calls—to spin up an
ephemeral local server. The redirect URI points to localhost on the first available port. The
server stops as soon as the request is intercepted or after a short timeout. It turned out to be an
elegant and seamless solution.
This journey was incredibly enriching. It pushed me to improve the layout for larger screens,
which—as a side benefit — greatly improved the experience for mobile users on tablets and foldables.
While it might seem like a small addition, providing a .deb package for every release is a win for
the open-source community. Having a dedicated desktop Fediverse client helps the ecosystem grow, and
I'm excited to see where the project goes next.
One of the most common complaints during Raccoon's first year of development, and a major reason why
some early adopters jumped ship, was the lack of support for push notifications.
It's a fair critique, but what many users didn't realize is that push notifications aren't just a
simple, client-side switch you flip. As the name suggests, notifications must be "pushed" to devices
by an external service the moment an event occurs.
To understand why this was such a hurdle for Raccoon, we first need to look at how traditional push
notifications work.
The standard architecture for push notifications is a three-way dance involving the client device,
the backend instance, and a third-party messaging relay acting as the "man in the middle".
Here is how they interact:
Client Application: the app registers with a third-party messaging service—usually tied
directly to the mobile operating system, like APNS for iOS or FCM for Android. The device obtains
a unique "push token" for that specific app installation and sends it to the backend via an
authenticated endpoint.
Application server: the backend stores these tokens and links them to the correct user
account. Whenever an event triggers a notification (based on the user's preferences), the backend
makes a server-to-server call to the messaging relay.
Messaging Relay: this service takes the backend's request and dispatches the message to
the device on a "best-effort" basis (handling edge cases like offline devices, app badges,
priority levels, custom sounds, etc.).
In short: the client app handles tokens and incoming messages, the application server manages user
preferences and triggers, and the messaging service handles the complex, platform-specific delivery.
While this architecture works flawlessly for centralized corporate apps, the rules change entirely
when you build for open-source networks and the Fediverse.
Raccoon has been strongly committed to zero reliance on closed-source, proprietary
third-party libraries, otherwise I would not even be able to distribute the app through F-Droid.
That immediately rules out standard integrations with FCM and APNS.
UnifiedPush is based on a slightly different paradigm.
Instead of a single, proprietary monolith, it splits the middleman into two separate components:
Push Server: it receives events directly from the application server through the WebPush open
protocol;1
Distributor: an app installed on the device which delivers those notifications to individual
apps through the UP protocol.
So, together with the application server and the client app, there are totally four components
involved:
Diagram of the interactions between components with UnifiedPush.
In this scenario, the client app registers in onto the distributor. The latter contacts the push
server to create an endpoint. Once obtained, the endpoint is delivered back to the client app, which
transmits it to the application server which stores the endpoint data associated to the user.
When a notification needs to be sent, the application server sends a message to the endpoint
it has stored, which points to the push server. The push server processes the message and the
distributor, which is subscribed for messages on the push server, proxies the notifications to the
client app.
Decoupling the push server and the distributor allows for more flexibility: you are free to use
whatever technology best suits you. E.g. on the server side you can choose a self-hosted server like
ntfy.sh or
NextPush which integrates with NextCloud,
SunUp, etc. whereas on the
client-side you can use the their counterparts of ntfy and SunUp on Android or dedicated apps (e.g.
KUnifiedPush on Linux).
Implementing this on Android turned out to be a game-changer. Thanks to the UnifiedPush Android SDK,
Raccoon can seamlessly configure distributors, detect when new ones are installed, and let the user
choose their preferred routing.
Recommendation
If you are setting this up on Android, I highly recommend using
Sunup as your distributor.
It is incredibly easy to set up and completely frictionless to use.
The result?
Finally, fully working, privacy-respecting push notifications! With this major milestone
ticked off the list, there's officially one excuse less to prefer other Fediverse clients. 😂 🦝
Hurray! A few days ago, JetBrains's blog published
a new post highlighting the
new default structure for KMP projects, which goes hand in hand with the migration to AGP 9.x.
This piece of news hit me last Friday evening. I had just left the office after a long, tiring work
week. I got into my car to drive home and opened YouTube, hoping to listen to a relaxing talk during
my commutetime… and voilà! There was Márton Braun uttering the frightening words: «We are
introducing a new default structure for KMP projects…»
And so there I was — suffering from framework-induced PTSD, nervously blinking and hysterically
chuckling alone in my car. I remembered how it took me several days to get my local builds for all
platforms, CI workflows, and integrations (Codecov and Weblate, just to mention a couple) up and
running correctly.
How on earth is the recommended structure changing again?!
For KMP projects like mine, this meant it was no longer possible to apply the
org.jetbrains.kotlin.multiplatform Gradle plugin alongside the com.android.library or
com.android.application plugin in the same module.1
Instead, the newcom.android.kotlin.multiplatform.library plugin was introduced.
Now, all shared modules must use the new KMP library plugin, while the main Android entry
point has to use com.android.application alone.
The previous approach — where all entry points (except iOS which was in a separate folder in order
to be opened as an Xcode project) coexisted in a single composeApp module — was no longer
viable.
Therefore, I had to create a shared library module, making the Android application a separate module
entirely. Ironically enough, this feels a lot like the standard structure we had back in 2023.
I felt relatively lucky because only my Raccoon for Friendica app used the structure they
recommended after 2023. My Raccoon for Lemmy app still had a shared module and a separate
androidApp module.
AGP 9.x also fundamentally changes the source set structure for common unit tests (running on the
host system) and instrumented tests (running on a device/simulator).
In my case, I used convention plugins, meaning I apply configurations via the Gradle Kotlin DSL and
various plugin DSLs rather than doing it manually.
Trying to figure this out with incredibly scarce and scattered documentation on how to do it
properly was a nightmare (and I had to fix it (e.g.
here and
here).
Not to mention that all Compose resources such as string translations had to be moved from the
composeApp mobile module to the shared module, which completely broke my crowdsourced
translations on Weblate.
I am truly caught in a love-hate relationship with KMP right now. More than ever, it feels like a
brittle foundation to build a long-term project upon.
«Why are you complaining?» you might ask. «After all, early-adoption has always been like this.»
And you're right. In the early years, this chaos was expected. Every new technology needs time to
settle down until the community reaches a consensus on best practices and shared conventions.
But KMP has been around for quite some time now, and it is supposed to be stable! Yet here we are,
and it feels like they introduce breaking changes every six months.
So here I am once again trying to follow their new standard as closely as possible. I'm doing
this in the desperate hope of minimizing the chances of everything melting down with the next
release of the Kotlin compiler, KSP, Gradle, AGP, Compose, or whatever else updates next.
At the very least, I’m hoping that by strictly adhering to these recommendations now, the next
migration will be less painful. It feels like offering sacrifices to a vengeful deity,
praying that my compliance today will earn me a little more mercy when the next storm hits.
As a conclusion, I hope you allow me this little rant. Also, please wish me luck—because as of right
now, I have no idea if my iOS build is actually working, and I won't know if any of my release
workflows are truly safe until my next beta deployment.
A note on terminology: in the rest of this article, the word "module" is used as a synonym of
subproject for brevity, even though in the Gradle lingo the most correct term is "subproject". ↩
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.
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.
To obtain the current window size class and react to its changes dynamically:
// in commonMain@ComposableexpectfungetWindowSizeClass():WindowSizeClass?// in the androidMain source set@ComposableactualfungetWindowSizeClass():WindowSizeClass?{valactivity=LocalActivity.currentcheckNotNull(activity){returnnull}returncalculateWindowSizeClass(activity)}// in iosMain@ComposableactualfungetWindowSizeClass():WindowSizeClass?{returncalculateWindowSizeClass()}// in jvmMain@ComposableactualfungetWindowSizeClass():WindowSizeClass?{returncalculateWindowSizeClass()}
Once we have the size class, we can create shared utilities to make our UI code more readable:
// all in the commonMain source set@ComposablefunisWidthSizeClassEqualOrAbove(other:WindowWidthSizeClass):Boolean{valcurrent=getWindowSizeClass()?.widthSizeClass?:WindowWidthSizeClass.Compactreturncurrent>=other}@ComposablefunisWidthSizeClassBelow(other:WindowWidthSizeClass):Boolean{valcurrent=getWindowSizeClass()?.widthSizeClass?:WindowWidthSizeClass.Compactreturncurrent<other}
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:
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.
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.
A screenshot of timeline / detail two-pane scaffold.
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.
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!
If you have
visited Raccoon's user manual
recently, you might have noticed a major shift. The layout is cleaner, navigation is intuitive,
there's a native search box, and you can finally toggle between light and dark themes.
It all started when I began exploring hosting alternatives like Codeberg.
As more open-source projects move toward independent platforms, I wanted to see how Raccoon's
documentation would fare outside the GitHub ecosystem.
While GitHub Pages is highly integrated, Codeberg favors a "DIY approach." This forced me to
re-evaluate my site generator. Jekyll served us well, but it felt aged. I needed something
modern, fast, and specifically built for software documentation.
I briefly considered Hugo, Jekyll's successor: it's incredibly powerful, but it
felt like overkill for just documentation.
Then I found out about Material for MkDocs.
It was an "ah-ha" moment: beautiful defaults, easy configuration, and a professional look
right out of the box.
However, a warning in the build logs gave me a pause:
Warning from the Material for MkDocs team
MkDocs 2.0, the underlying framework of Material for MkDocs, will introduce
backward-incompatible changes, including:
All plugins will stop working – the plugin system has been removed
All theme overrides will break – the theming system has been rewritten
Following the link in the error message I found
this article,
explaining that MkDocs 1.x is essentially unmaintained and 2.x is heading in a direction
incompatible with the philosophy of Material for MkDocs.
Zensical isn't just another theme; it's the successor to Material for MkDocs, built to solve the
fragmentation and maintenance issues of the MkDocs ecosystem. It takes everything I loved about
Material — the aesthetics, the ease of use, the rich feature set — and builds it on a more stable,
forward-looking foundation.
The migration was fairly easy, thanks to sane defaults and exhaustive documentation. Some of the
standout features include:
Custom palettes & Dark Mode: effortless switching between light and dark themes;
Native search: fast, client-side search that just works;
Organization: powerful categorization with tags and multi-language support.
But what truly shines are the Markdown extensions. They allow for a rich documentation experience
with features like admonitions, collapsible details, syntax highlighting, code tabs,
MathJax formulas, and even emojis.
If you are managing a software project and want documentation that looks as good as your code,
Zensical is the way to go.
Question
What about you? Have you found a documentation tool that changed the way you share your work?