Building a Living Design System

How We Created an Automated Android Component Catalog That Actually Works

By Pedro Santos

Creating effective design systems is notoriously difficult. Everyone’s aware of the benefits, but most stories – for whatever reason – don’t have a happy ending.

At Doist, we wanted our experience to be different.

We started the design side of our design system some time ago and made big strides on that front. When it came the time to map design to code, we struggled. Our codebase and processes weren’t designed to have a design-system first approach, so we had to work through it.

This article focuses specially on the components part of the design system and how we came up with an automated Components Catalog that makes the bridge between the components’ design and the code that implements it.

Let’s dive in.

Doist’s design system

Our design team has spent the last few years building and iterating on our design system.

It’s hosted in Figma and consists of four pillars:

Doist didn’t want to have this design system just on the design side. The organization wanted to port this to our apps and map it to code that actually works, to leverage all the possibilities that having a well-integrated design system opens up for us.

The Problem

Something that’s not automated or easy to check automatically tends to rot. This is what we observed with our original design system implementation.

Someone implemented one version of a component in the scope of a given feature, then another person on the team wasn’t aware of it or had a design spec with a similar but slightly different component, ending up implementing a different version. Suddenly, there are four variations of the same button which we would have to deal with sooner or later.

But these struggles are bidirectional.

Our design team also felt the pain of not knowing which UI elements we had built as reusable components versus one-off feature code, which subset of their designs we had actually implemented, and how our implementations looked compared to their specs. The disconnect was also conceptual—designers might consider something one component while we treated it as multiple unrelated pieces of UI that happened to look alike.

This was harming our adoption of the design system, and we wanted to fix it at the root.

Our solution: An Automated Component Catalog

We knew that, whatever solution we came up with, had to be easy to adopt. It had to be as automated and as seamless in our day to day as possible. Here’s the breakdown of how we adapted and structured our codebase and tools to embrace a design-system first mindset.

The Foundation: Smart Module Architecture

We organized our components into focused modules:

This separation keeps things clean and sets a clear boundary of what are one-off pieces of UI, what are effectively components and where to search for each of these.

Component Guidelines That Actually Matter

Instead of vague principles, we established concrete, enforceable rules:

Naming Consistency: Component names must exactly match its definition in Figma. No exceptions. This simple rule eliminates the mental overhead of translating between design and code.

Compose-first: Our codebase has both Views and Compose code, but we decided from the get-go that our components would be Compose-only. We could leverage AbstractComposeView as a bridge if needed.

No Magic Values: Every component uses our theming system and is parameter-driven, no magic values and resources are passed already resolved. This makes them reusable and predictable.

Documentation & Testing Mandatory: Every component requires thorough kdoc documentation, multiple @Previews that showcase all possible states for each component and Paparazzi snapshot tests. This isn’t just about testing—these previews become the visual documentation in our catalog.

How we automated it all

After having all the necessary pieces in place to have a great component catalog with the same quality standard as our frontend team’s Storybook , we needed to actually generate and deploy it.

For that, we use 3 main tools:

Dokka

We chose Dokka to generate the component catalog’s info from our component’s kdoc. This ensures that code and documentation are in sync. As we have markdown support for free, we can format the text in a way that looks good when converted to HTML.

Here’s how the documentation of one of our components looks like:

/**
 * A composable that displays a switch component. [Figma](link to the component definition in Figma")
 *
 * The switch is a toggle component that allows users to enable or disable a setting or feature.
 *
 * @param checked The current state of the switch (true for on, false for off).
 * @param onCheckedChange Callback invoked when the user clicks the switch to change its state.
 * @param modifier Optional [Modifier] to be applied to the switch.
 * @param enabled Whether the switch is enabled and can be interacted with. When disabled,
 *               the switch will appear in a non-interactive state.
 *
 * # Examples:
 * <img src="Name of the snapshot generated by Paparazzi that showcases how the component looks like"/>
 *
 * @includeTokenInfo
 *
 */

We first describe the general behavior of the component and its purpose, with a link to its design counterpart.

Then we describe its parameters.

Then we have an Examples section, where we add as many <img> tags as we want and the source for these are our Paparazzi snapshots.

Lastly, we include a custom @includeTokenInfo annotation that is a placeholder for displaying which tokens – colors, text styles – are used in this component. More on this below.

A bit of scripting wizardry

We extracted 2 important steps to scripts.

Inline image previews

The first is a shell script that reads which Paparazzi snapshots are referenced in the documentation and then copies them to the component catalog directory so they can be embedded as inline previews in the docs.

Here’s the structure of our script.

#!/bin/bash

# Ensure git lfs is available
if ! command -v git-lfs &> /dev/null; then
    echo "❌ Error: git-lfs is not installed"
    exit 1
fi

# Ensure we have the actual LFS content for the snapshots images
echo "📥 Pulling Git LFS content for snapshots images..."
git lfs pull --include="path/to/your/paparazzi/snapshots/images"

# Find all HTML files and process their image references
echo "🔍 Searching for image references in HTML files..."
find component-catalog -type f -name "*.html" | while read -r html_file; do
  # Get the directory of the HTML file
  html_dir=$(dirname "$html_file")
  
  # Extract image references from this HTML file
  echo "📄 Processing HTML file: $html_file"
  grep -h -o 'src="[^"]*\.png"' "$html_file" | \
  sed 's|src="||' | \
  sed 's|"||' | \
  sort -u | \
  while read -r img; do
    echo "📄 Found reference to image: $img"
    if [ -f "path/to/your/paparazzi/snapshots/images/$img" ]; then
      echo "📦 Copying image: $img to $html_dir/"
      # Copy with preserved permissions and ensure it's readable
      cp -p "path/to/your/paparazzi/snapshots/images/$img" "$html_dir/$img" && \
      chmod 644 "$html_dir/$img"
      echo "✅ Successfully copied: $img"
    else
      echo "❌ Error: Referenced image not found in snapshots: $img"
      exit 1
    fi
  done
done

Inline colors and text styles references

The second is a Kotlin script that grabs all references to our design system colors and text styles in a given component’s file, and then replaces our custom @includeTokenInfo annotation with a formatted text listing all those references.

This helps our design system understand which colors and text styles are used by a given component and identify any possible outliers that aren’t clear from the inline previews.

import java.io.File

// Directory containing the components to scan.
val ComponentsSourceDir = File("path/to/your/components/directory")
// Colors format is AppTheme.colors.<group>.<color>
private val RegexColors = "AppTheme\\.colors\\.[a-zA-Z0-9_]+\\.[a-zA-Z0-9_]+".toRegex()
// Typography format is AppTheme.typography.<type>>
private val RegexTypography = "AppTheme\\.typography\\.[a-zA-Z0-9_]+".toRegex()
// The component's KDoc contains a custom @includeTokenInfo annotation that we'll replace and inject
// the generated tokens' info.
private val RegexIncludeTokenInfo = Regex(
    pattern = """(@includeTokenInfo)(.*?)?(\*\/)""",
    options = setOf(RegexOption.DOT_MATCHES_ALL)
)

/**
 * Gathers all colors and typography references in [content] and transforms them into a Markdown
 * formatted list.
 */
private fun buildTokenInfo(content: String): String {
    val colors = RegexColors.findAll(content)
        .map { it.value }
        .toSortedSet()
        .ifEmpty { setOf("Could not find any color references in this component.") }
    val typography = RegexTypography.findAll(content)
        .map { it.value }
        .toSortedSet()
        .ifEmpty { setOf("Could not find any typography references in this component.") }
    return """
# Tokens used in this component:
 * ## Colors
${colors.joinToString(separator = "\n") { " *  - `$it`" }}
 * ## Typography
${typography.joinToString(separator = "\n") { " *  - `$it`" }}
 *
    """.trimMargin()
}

println("🔍 Scanning in ${ComponentsSourceDir.absolutePath}")
ComponentsSourceDir.walkTopDown()
    .filter { it.extension == "kt" }
    .forEach { file ->
        println("🔨 Processing file: ${file.name}")

        val fileContent = file.readText()
        val updatedContent = RegexIncludeTokenInfo.replace(fileContent) { matchResult ->
            // Optional content after the annotation.
            val restOfKDoc = matchResult.groupValues[2]
            // KDoc's closing */ element.
            val kDocEnd = matchResult.groupValues[3]

            // Replace the annotation with token, preserving whatever was after it.
            "${buildTokenInfo(fileContent)}${restOfKDoc}$kDocEnd"
        }

        if (fileContent != updatedContent) {
            println("  ✅ Updated ${file.path}")
            file.writeText(updatedContent)
        } else {
            println("  ❌ ${file.name} did not include any @includeTokenInfo annotation.")
        }
    }

This script turns this:

 *
 * @includeTokenInfo
 *

Into this:

 * # Tokens used in this component:
 * ## Colors
 *  - `AppTheme.colors.selectable.background`
 *  - `AppTheme.colors.selectable.primarySelectedFill`
 *  - `AppTheme.colors.selectable.primaryUnselectedTint`
 * ## Typography
 *  - `AppTheme.typography.Body2`

An important note is that we don’t commit the transformed version into the repository. This ensures that, every time the script runs on CI we update it to have all the latest token references.

GitHub Actions & Pages

We leverage GitHub Actions to update the documentation and publish it in a 2-step process:

Step 1 – Documentation Generation

When someone merges a PR that touches our app-ui-system module, our first workflow springs into action. It updates the documentation (including images and tokens) and creates a new PR with the updated docs.

The author of the changes that triggered that documentation update is requested as the reviewer so they can validate their changes. Here’s how that workflow looks like:

name: Check component catalog updates

on:
  pull_request:
    types:
      - closed
    branches:
      - main
    paths:
      - 'app-ui-system/**'

jobs:
  component-catalog-update:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.DOIST_BOT_TOKEN }}
          submodules: recursive
          fetch-depth: 0
          lfs: true

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: "temurin"
          java-version: "17"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: true

      - name: Inline tokens info
        run: kotlinc -script <your_inline_tokens_script>

      - name: Generate Component Catalog (dokka) documentation
        run: ./gradlew :app-ui-system:dokkaHtml

      - name: Discard changes to component source files
        run: git checkout -- <path to your components' directory>

      - name: Copy preview images
        run: <your_copy_images_script>

      - name: Check for documentation changes
        id: git_diff
        run: |
          git fetch origin main
          git add component-catalog
          git diff --quiet origin/main -- component-catalog || echo "changed=true" >> $GITHUB_OUTPUT

      - name: Create Pull Request with updated docs
        if: steps.git_diff.outputs.changed == 'true'
        uses: your-preferred-create-PR-action
        with:
          # All relevant attributes.
          reviewers: ${{ github.event.pull_request.user.login }}

Step 2 – Publishing

When that documentation update PR gets merged, our second workflow deploys everything to GitHub Pages.

name: Publish Component Catalog to GitHub Pages

on:
  pull_request_target:
    types: [ closed ]
    branches:
      - main

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  component-catalog-publish:
    # Only run if the PR was merged and has the component-catalog branch prefix.
    if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'component-catalog/')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Upload artifact for GitHub Pages
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./component-catalog

      - name: Deploy to GitHub Pages
        uses: actions/deploy-pages@v4

The result

An online component catalog that’s always up-to-date with the latest code and available for all stakeholders to check on the platform of their choice.

Documentation page for the Switch component
Documentation page for the Switch component.

The Developer Experience

One of our requirements for this component catalog and design system implementation was for the developer experience to create components and keep the documentation up-to-date to be as smooth as the design team’s experience interacting with it.

So, before rolling this out to the whole team, we had the following:

By having a solid infrastructure with examples and a clear workflow, the adoption of this idea and process went very smoothly.

Why This Approach Works

Minimal Maintenance Overhead: Developers don’t need to remember to update documentation. It happens automatically as part of their normal workflow.

Visual First: Every component shows up in the catalog with actual rendered previews, not just code snippets. Designers can see exactly how their designs translate to code.

Single Source of Truth: The catalog reflects the actual implementation, not some idealized version that exists only in documentation.

Quality Gates: The Paparazzi snapshot tests catch visual regressions before they make it to production, while the preview requirements ensure every component is actually usable.

Designers are also editors: All this happens within GitHub and is based on human-written and readable text, so our designers can open pull requests and propose updates to the documentation by themselves.

Lessons Learned

Automation is Everything: The moment documentation becomes a manual step, it starts falling behind. Automate relentlessly.

Constraints Enable Creativity: Our seemingly strict guidelines actually make developers more productive by eliminating decision fatigue and ensuring consistency.

Make the Right Thing Easy: When following best practices is the path of least resistance, teams naturally build better software.

Alternatives Considered

Before settling on our automated catalog approach, we explored several other options:


Building a component catalog that teams actually use requires more than just good documentation — it requires rethinking how design systems integrate into your development workflow. By automating the tedious parts and focusing on developer (and designer) experience, you can create something that truly serves your team instead of becoming another maintenance burden.

Given this is a yet-to-be-figured-out challenge in the Android community, we hope that by sharing how we approached the problem we can help other teams and spark more discussions on how to make this a useful tool rather than yet-another tricky problem to solve.

Questions, suggestions? Anytime. 🚀