<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DevSnack</title>
    <description>The latest articles on DEV Community by DevSnack (@devsnack).</description>
    <link>https://dev.to/devsnack</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3987611%2Fe6ad5e35-ad4a-43c3-9f55-faf93f1fa917.png</url>
      <title>DEV Community: DevSnack</title>
      <link>https://dev.to/devsnack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devsnack"/>
    <language>en</language>
    <item>
      <title>Building a token layer Material 3 actually respects</title>
      <dc:creator>DevSnack</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:58:13 +0000</pubDate>
      <link>https://dev.to/devsnack/building-a-token-layer-material-3-actually-respects-4acl</link>
      <guid>https://dev.to/devsnack/building-a-token-layer-material-3-actually-respects-4acl</guid>
      <description>&lt;p&gt;&lt;code&gt;MaterialTheme&lt;/code&gt; takes three things: a &lt;code&gt;ColorScheme&lt;/code&gt;, a &lt;code&gt;Typography&lt;/code&gt;, and a &lt;code&gt;Shapes&lt;/code&gt;. Your design system almost certainly has more than three things in it — a spacing scale at minimum, probably elevation semantics, maybe a density knob — so at some point you write a layer that carries the rest.&lt;/p&gt;

&lt;p&gt;That layer is about forty lines. Three of the decisions inside it are the kind you discover you got wrong six months later, and one of them I did get wrong, in code that's published on Maven Central right now. This is the walkthrough I wanted when I wrote FormaUI's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually filling in
&lt;/h2&gt;

&lt;p&gt;Worth being precise about the gap, because it's smaller than the discourse suggests. &lt;code&gt;MaterialTheme&lt;/code&gt; handles colour, type and corners properly — those are genuinely themeable and genuinely inherited. What it has no concept of is &lt;strong&gt;spacing&lt;/strong&gt; (no dimension tokens at all; every component's padding is a private constant in androidx), &lt;strong&gt;elevation as a semantic&lt;/strong&gt; (per-component defaults exist, but there's no &lt;code&gt;theme.elevation.raised&lt;/code&gt; to point at), &lt;strong&gt;density&lt;/strong&gt;, and anything domain-specific — chart series colours, a &lt;code&gt;success&lt;/code&gt; role, a tabular-numeral text style.&lt;/p&gt;

&lt;p&gt;So the layer's job is narrow: carry the tokens M3 doesn't model, and make sure the tokens M3 &lt;em&gt;does&lt;/em&gt; model are actually reaching it. Both halves matter, and the second one is where most hand-rolled layers quietly fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Three pieces: composition locals to hold the values, a wrapper that provides them, an object to read them back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;LocalFormaSpacing&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;staticCompositionLocalOf&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;FormaSpacing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;LocalFormaShapes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;staticCompositionLocalOf&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;FormaShapes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;LocalFormaTypography&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;staticCompositionLocalOf&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;FormaTypography&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;FormaTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FormaColorScheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultColorScheme&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FormaTypography&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultTypography&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FormaShapes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultShapes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;dynamicColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;darkTheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isSystemInDarkTheme&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dynamicScheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dynamicColor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;dynamicColorSchemeOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;darkTheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;resolvedColorScheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dynamicScheme&lt;/span&gt;
        &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;darkTheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dark&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;light&lt;/span&gt;

    &lt;span class="nc"&gt;CompositionLocalProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;LocalFormaSpacing&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="nc"&gt;FormaSpacing&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;LocalFormaShapes&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;LocalFormaTypography&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;colorScheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolvedColorScheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;shapes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;typography&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole wrapper. The interesting parts are &lt;code&gt;staticCompositionLocalOf&lt;/code&gt;, &lt;code&gt;shapes.material&lt;/code&gt;, and one line that's a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: static or dynamic local?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;compositionLocalOf&lt;/code&gt; tracks reads. Change the provided value and Compose recomposes exactly the composables that read it. &lt;code&gt;staticCompositionLocalOf&lt;/code&gt; does not track reads — it's cheaper to read, and the price is that changing the provided value invalidates the &lt;strong&gt;entire&lt;/strong&gt; content lambda beneath the provider.&lt;/p&gt;

&lt;p&gt;For theme tokens, static is the right call, and the reason is a question about your app rather than about Compose: &lt;em&gt;how often does this value change?&lt;/em&gt; A theme's tokens change essentially never — once at startup, maybe again when the user flips dark mode, which is a full-screen repaint anyway. Paying read-tracking overhead on every &lt;code&gt;FormaTheme.spacing.md&lt;/code&gt; in the tree to optimise a transition that already redraws everything is the wrong trade.&lt;/p&gt;

&lt;p&gt;It becomes the wrong call the moment a token is genuinely dynamic. If you ship a compact/comfortable density switch that users toggle in a settings sheet, or a per-screen spacing override, a static local means every toggle re-runs your whole app's composition. That's the case for &lt;code&gt;compositionLocalOf&lt;/code&gt;, and it's worth knowing which of the two you signed up for before the feature request arrives.&lt;/p&gt;

&lt;p&gt;Both of Material's own theme locals are static, for the same reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: hand your tokens to &lt;code&gt;MaterialTheme&lt;/code&gt; as well
&lt;/h2&gt;

&lt;p&gt;This is the half people skip, and it's the one that decides whether the app looks designed or half-migrated.&lt;/p&gt;

&lt;p&gt;Your components read &lt;code&gt;FormaTheme.shapes.lg&lt;/code&gt; and get 12dp corners. Fine. But your app also contains M3 components you never wrapped — a &lt;code&gt;DropdownMenu&lt;/code&gt;, an &lt;code&gt;AlertDialog&lt;/code&gt;, a &lt;code&gt;Surface&lt;/code&gt; someone reached for directly — and those read &lt;code&gt;MaterialTheme.shapes&lt;/code&gt;. If you only provide your own local, those keep M3's stock 12dp/16dp/28dp and your carefully tightened corners stop at the boundary of your own component set.&lt;/p&gt;

&lt;p&gt;So the token class exposes a translation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Immutable&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FormaShapes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;none&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RoundedCornerShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RoundedCornerShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RoundedCornerShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RoundedCornerShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RoundedCornerShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RoundedCornerShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CircleShape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;full&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CornerBasedShape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CircleShape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;material&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Shapes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Shapes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;extraSmall&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;small&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;medium&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;large&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;extraLarge&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight tiers down to M3's five slots:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your tier&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;M3 slot&lt;/th&gt;
&lt;th&gt;M3 stock value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;xs&lt;/td&gt;
&lt;td&gt;4dp&lt;/td&gt;
&lt;td&gt;extraSmall&lt;/td&gt;
&lt;td&gt;4dp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sm&lt;/td&gt;
&lt;td&gt;6dp&lt;/td&gt;
&lt;td&gt;small&lt;/td&gt;
&lt;td&gt;8dp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;md&lt;/td&gt;
&lt;td&gt;8dp&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;12dp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lg&lt;/td&gt;
&lt;td&gt;12dp&lt;/td&gt;
&lt;td&gt;large&lt;/td&gt;
&lt;td&gt;16dp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;xl&lt;/td&gt;
&lt;td&gt;16dp&lt;/td&gt;
&lt;td&gt;extraLarge&lt;/td&gt;
&lt;td&gt;28dp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;none, pill, full&lt;/td&gt;
&lt;td&gt;0dp / pill / pill&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;no slot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things fall out of that table. The first is that the mapping is lossy in both directions — you have tiers M3 has nowhere to put, and a semantic disagreement in the middle where your &lt;code&gt;md&lt;/code&gt; is a control corner and M3's &lt;code&gt;medium&lt;/code&gt; is a card corner. Pick which of your tiers is the least-wrong fit for each slot, then write the mapping down where someone can read it, because the next person will assume it's identity.&lt;/p&gt;

&lt;p&gt;The second is that a stock &lt;code&gt;DropdownMenu&lt;/code&gt; inside this theme comes out at 8dp instead of 12dp. That's not a leak; it's the entire point. Do the same for typography — a &lt;code&gt;Typography&lt;/code&gt; handed to &lt;code&gt;MaterialTheme&lt;/code&gt; — and every un-wrapped &lt;code&gt;Text&lt;/code&gt; in your app inherits your type scale for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: the accessor object, and &lt;code&gt;@ReadOnlyComposable&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The read side mirrors Material's &lt;code&gt;MaterialTheme&lt;/code&gt; object convention, so it's already familiar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;FormaTheme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FormaSpacing&lt;/span&gt;
        &lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="nd"&gt;@ReadOnlyComposable&lt;/span&gt;
        &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalFormaSpacing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FormaShapes&lt;/span&gt;
        &lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="nd"&gt;@ReadOnlyComposable&lt;/span&gt;
        &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalFormaShapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@ReadOnlyComposable&lt;/code&gt; is the part worth understanding rather than copying. It asserts to the compiler that this composable only &lt;em&gt;reads&lt;/em&gt; — emits no nodes, introduces no group of its own — which lets Compose generate a cheaper call with no recompose scope attached. For a getter that returns &lt;code&gt;SomeLocal.current&lt;/code&gt; it's free performance and exactly correct.&lt;/p&gt;

&lt;p&gt;It's also a promise you can break silently, because putting a &lt;code&gt;remember&lt;/code&gt; inside one is a lie about group structure that nothing will flag. The rule I use: if the getter's body is a &lt;code&gt;.current&lt;/code&gt;, a constant, or arithmetic over those, annotate it. If it contains &lt;code&gt;remember&lt;/code&gt;, &lt;code&gt;LaunchedEffect&lt;/code&gt;, or anything that emits, don't.&lt;/p&gt;

&lt;p&gt;The same annotation is what makes per-component defaults work as theme reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;FormaButtonDefaults&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Shape&lt;/span&gt;
        &lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="nd"&gt;@ReadOnlyComposable&lt;/span&gt;
        &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;md&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which, incidentally, is the fix for M3's button shape not being themeable — &lt;code&gt;ButtonDefaults.shape&lt;/code&gt; resolves to a hardcoded &lt;code&gt;CircleShape&lt;/code&gt; rather than reading &lt;code&gt;MaterialTheme.shapes&lt;/code&gt;. I wrote about that in more detail in the &lt;a href="https://www.formaui.dev/blog/material-3-vs-formaui" rel="noopener noreferrer"&gt;token-by-token comparison&lt;/a&gt;; the short version is that a default declared as a theme read is themeable and a default declared as a constant is not, and that distinction is most of what an opinionated layer is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong: allocating tokens inside the provider
&lt;/h2&gt;

&lt;p&gt;Look at that provider again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;CompositionLocalProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;LocalFormaSpacing&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="nc"&gt;FormaSpacing&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;   &lt;span class="c1"&gt;// ← new instance, every time&lt;/span&gt;
    &lt;span class="nc"&gt;LocalFormaShapes&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;LocalFormaTypography&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FormaSpacing()&lt;/code&gt; constructs a fresh object on every composition of &lt;code&gt;FormaTheme&lt;/code&gt;. &lt;code&gt;FormaSpacing&lt;/code&gt; is annotated &lt;code&gt;@Immutable&lt;/code&gt; — but &lt;code&gt;@Immutable&lt;/code&gt; is a promise to the compiler about &lt;em&gt;mutation&lt;/em&gt;, not a generated &lt;code&gt;equals&lt;/code&gt;. It's a plain &lt;code&gt;class&lt;/code&gt;, so equality is identity, so every one of those fresh instances compares unequal to the last one.&lt;/p&gt;

&lt;p&gt;Provide a changed value to a &lt;strong&gt;static&lt;/strong&gt; local and the entire content lambda beneath it is invalidated. Which means: every recomposition of &lt;code&gt;FormaTheme&lt;/code&gt; recomposes the whole app underneath it, forever, because of a constructor call in an argument list.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;defaultShapes()&lt;/code&gt; has the same shape of mistake — &lt;code&gt;fun defaultShapes(): FormaShapes = FormaShapes()&lt;/code&gt;, a new instance per call, used as a parameter default. Typography got it right, and the contrast is instructive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;rememberBrandTypography&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;FormaTypography&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;family&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rememberPublicSansFamily&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one is remembered, so it's the same instance across recompositions, so providing it is a no-op after the first pass. One of three tokens got the treatment all three needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does it actually cost?&lt;/strong&gt; In the common case, close to nothing — and I'd rather say that than oversell my own bug. &lt;code&gt;FormaTheme&lt;/code&gt; normally sits at the app root where its arguments are stable and &lt;code&gt;isSystemInDarkTheme()&lt;/code&gt; flips maybe twice a day, so it seldom recomposes and the invalidation seldom fires. The case where it hurts is a &lt;code&gt;FormaTheme&lt;/code&gt; nested inside a subtree that recomposes often, which is a legitimate thing to do for a themed section of a screen and is exactly where nobody would think to look for the cause.&lt;/p&gt;

&lt;p&gt;The fixes are boring, which is the tell that the original code was carelessness rather than a trade-off: hoist the default to a top-level &lt;code&gt;private val&lt;/code&gt; (or &lt;code&gt;remember&lt;/code&gt; it) so one instance exists for the process, and give the token classes &lt;code&gt;equals&lt;/code&gt;/&lt;code&gt;hashCode&lt;/code&gt; — a &lt;code&gt;data class&lt;/code&gt;, or &lt;code&gt;@Immutable&lt;/code&gt; plus a hand-written pair — so even a fresh instance compares equal to the old one and the provider stops churning.&lt;/p&gt;

&lt;p&gt;The general lesson, and the reason I'm writing it down: &lt;strong&gt;&lt;code&gt;@Immutable&lt;/code&gt; and &lt;code&gt;@Stable&lt;/code&gt; describe how a type behaves, not how it compares.&lt;/strong&gt; Compose's skipping logic runs on &lt;code&gt;equals&lt;/code&gt;. A token class without one reports "I changed" every time you build a new one, and a composition-local provider is the place where that becomes a whole-subtree cost instead of a single wasted recomposition.&lt;/p&gt;

&lt;p&gt;If you're auditing your own layer today, that's the one-line check: are your token classes &lt;code&gt;data class&lt;/code&gt;es, and is the instance you provide the same instance you provided last frame?&lt;/p&gt;

&lt;h2&gt;
  
  
  The limitation I can't design around
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FormaTheme&lt;/code&gt; takes &lt;code&gt;colorScheme&lt;/code&gt;, &lt;code&gt;typography&lt;/code&gt; and &lt;code&gt;shapes&lt;/code&gt;. It does not take &lt;code&gt;spacing&lt;/code&gt;, and &lt;code&gt;LocalFormaSpacing&lt;/code&gt; is &lt;code&gt;internal&lt;/code&gt;. So FormaUI's 4dp grid — &lt;code&gt;xxs&lt;/code&gt; 4, &lt;code&gt;xs&lt;/code&gt; 8, &lt;code&gt;sm&lt;/code&gt; 12, &lt;code&gt;md&lt;/code&gt; 16, &lt;code&gt;lg&lt;/code&gt; 24, &lt;code&gt;xl&lt;/code&gt; 32, &lt;code&gt;xxl&lt;/code&gt; 48, &lt;code&gt;section&lt;/code&gt; 96 — is a fixed contract. You can read it. You cannot replace it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FormaSpacing&lt;/code&gt;'s own KDoc says &lt;em&gt;"Every value is overridable; construct a custom &lt;code&gt;FormaSpacing&lt;/code&gt; to retune the rhythm."&lt;/em&gt; You can construct one. There is no way to install it. That sentence is wrong, it's mine, and it's the sort of thing that only gets found when someone tries.&lt;/p&gt;

&lt;p&gt;How it happened is ordinary and probably instructive. The hard requirement was that no component hardcode a &lt;code&gt;dp&lt;/code&gt; value, which needs the scale to be &lt;em&gt;ambient&lt;/em&gt;. Making it &lt;em&gt;configurable&lt;/em&gt; was a separate piece of work with no internal customer, so it didn't ship. Ambient-but-fixed is a coherent stopping point that reads, from outside, like an oversight — because it is one.&lt;/p&gt;

&lt;p&gt;If your product needs a 5dp or 10dp rhythm, that's a real reason to write your own layer instead of adopting this one, and I'd rather you knew before the dependency than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;If you're writing this layer today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static locals for tokens that never change; dynamic locals for anything a user can toggle.&lt;/strong&gt; Decide deliberately — the failure mode is invisible until it's a jank report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provide your tokens to &lt;code&gt;MaterialTheme&lt;/code&gt; too&lt;/strong&gt;, or un-wrapped M3 components keep Google's defaults inside your app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the tier→slot mapping down.&lt;/strong&gt; It's lossy and nobody will guess it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data class&lt;/code&gt; your token classes&lt;/strong&gt;, and provide a stable instance. It's what stops a static local from invalidating your whole tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@ReadOnlyComposable&lt;/code&gt; on every pure token getter&lt;/strong&gt;, and nowhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make every token a constructor parameter from day one&lt;/strong&gt;, even ones nobody's asked to override. Retrofitting one changes a public signature, and by then people are calling it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Colour, type and corners you get from Material for free if you wire them up. Everything else is yours, and the layer is short enough that the only real risk is being casual with it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/devsnackio/forma-ui" rel="noopener noreferrer"&gt;FormaUI&lt;/a&gt; is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done. The theme layer described here is &lt;code&gt;dev.formaui:core&lt;/code&gt;; every token is documented on the &lt;a href="https://www.formaui.dev/theming" rel="noopener noreferrer"&gt;theming page&lt;/a&gt;, and you can &lt;a href="https://www.formaui.dev/components" rel="noopener noreferrer"&gt;try the components live in your browser&lt;/a&gt;. Every API is &lt;code&gt;@ExperimentalFormaUiApi&lt;/code&gt; pre-1.0, including the spacing gap above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>designsystem</category>
      <category>programming</category>
    </item>
    <item>
      <title>The press animation that never plays on a fast tap</title>
      <dc:creator>DevSnack</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:57:17 +0000</pubDate>
      <link>https://dev.to/devsnack/the-press-animation-that-never-plays-on-a-fast-tap-2k8d</link>
      <guid>https://dev.to/devsnack/the-press-animation-that-never-plays-on-a-fast-tap-2k8d</guid>
      <description>&lt;p&gt;FormaUI's buttons dip 3% when you press them. It's a small thing — a scale to 0.97 layered over the Material ripple — and the first implementation was two lines and obviously correct.&lt;/p&gt;

&lt;p&gt;It worked in the interactive preview. It worked when I held a button down. It did nothing whatsoever when anyone tapped one, which is the only way anyone uses a button.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version that looks right
&lt;/h2&gt;

&lt;p&gt;Here it is, and I'd guess most Compose codebases contain something very close:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pressed&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectIsPressedAsState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scale&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;animateFloatAsState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pressed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;0.97f&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;1f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;graphicsLayer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;scaleX&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
    &lt;span class="n"&gt;scaleY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three idiomatic APIs, no cleverness, reads like a sentence. Hold the button and it dips exactly as intended. Tap it and you get a flicker of maybe a third of a percent, which on a real screen is indistinguishable from nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why: &lt;code&gt;animateFloatAsState&lt;/code&gt; retargets, it doesn't queue
&lt;/h2&gt;

&lt;p&gt;A tap is two interactions a few tens of milliseconds apart: &lt;code&gt;PressInteraction.Press&lt;/code&gt;, then &lt;code&gt;PressInteraction.Release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;animateFloatAsState&lt;/code&gt; is a single animation whose &lt;em&gt;target&lt;/em&gt; you change. &lt;code&gt;Press&lt;/code&gt; sets the target to 0.97 and the animation starts moving. &lt;code&gt;Release&lt;/code&gt; sets it back to 1.0 — and retargeting doesn't restart or finish anything, it redirects the in-flight animation from wherever it currently is, preserving velocity. On a fast tap "wherever it currently is" is a couple of frames into a 3% journey. The animation dutifully turns around and goes home, having travelled almost no distance.&lt;/p&gt;

&lt;p&gt;Springs make it worse in a way that's easy to miss: a spring approaches its target asymptotically and stops within a visibility threshold, so there is no moment where the value &lt;em&gt;is&lt;/em&gt; 0.97 and could be observed. There's no "the dip happened" state to preserve, because the dip is an approach rather than an arrival.&lt;/p&gt;

&lt;p&gt;None of this is a bug in &lt;code&gt;animateFloatAsState&lt;/code&gt;. It is precisely correct for a value that &lt;strong&gt;tracks state&lt;/strong&gt; — a colour that follows selection, a chevron rotation that follows expanded/collapsed. If the state flips back before the animation lands, cutting it short is what you want; the user changed their mind and the UI should follow.&lt;/p&gt;

&lt;p&gt;A press dip isn't that. It's an &lt;strong&gt;acknowledgement of an event&lt;/strong&gt;. The user tapped; the interface owes them a confirmation that it noticed; the confirmation has a minimum legible duration regardless of how fast they lifted their finger. Modelling an event as a state and interpolating toward it is the whole mistake, and it took me embarrassingly long to see because the code reads so well.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe: a queue of events
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Animatable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;LaunchedEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pressedScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;downAnimationSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;animationSpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;interaction&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;PressInteraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Press&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;animateTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pressedScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;downAnimationSpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;PressInteraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Release&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;PressInteraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Cancel&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;animateTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;animationSpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;graphicsLayer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;scaleX&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="n"&gt;scaleY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The load-bearing word is &lt;code&gt;collect&lt;/code&gt;, and it isn't doing anything animation-specific.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Flow.collect&lt;/code&gt;'s body is a suspend function, and a flow will not emit its next value until the current invocation of that body returns. &lt;code&gt;Animatable.animateTo&lt;/code&gt; suspends until its animation finishes. Put those two facts together: a &lt;code&gt;Release&lt;/code&gt; that arrives while the dip is still playing &lt;strong&gt;cannot be handled&lt;/strong&gt; until the dip completes. It waits its turn in the collector, then the spring-back plays from the fully-dipped position.&lt;/p&gt;

&lt;p&gt;The dip always plays in full — not because of a special case or a minimum-duration guard, but because sequential collection is what &lt;code&gt;collect&lt;/code&gt; means. The mechanism transfers, which is why it's worth remembering: any "this feedback must complete even if the state that triggered it is already gone" problem has the same shape, and the answer is a queue of events rather than a function of state.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Animatable&lt;/code&gt; rather than &lt;code&gt;animateFloatAsState&lt;/code&gt; because we now need to &lt;em&gt;drive&lt;/em&gt; the animation from a coroutine and observe its completion, which is exactly the boundary between those two APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two specs, not one
&lt;/h2&gt;

&lt;p&gt;Guaranteeing the dip plays in full has an immediate consequence: &lt;strong&gt;the dip's duration is now a floor on how long the whole interaction takes.&lt;/strong&gt; A bouncy 400ms spring on the way down means every tap feels like the button is thinking about it.&lt;/p&gt;

&lt;p&gt;So the motion is split, and the two halves want opposite things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Spec&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Press dip&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tween(100ms, FastOutSlowInEasing)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deterministic and short. It runs under the finger where the user is already looking, and it's the part that can't be interrupted, so it has to be cheap.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring(DampingRatioLowBouncy, StiffnessMedium)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This one can be as expressive as you like — nothing is waiting behind it. The slight overshoot is what makes the release read as springy rather than mechanical.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A fixed tween on the way down and a bouncy spring on the way back was not the first thing I'd have guessed. It came out of the constraint rather than taste, which is usually a sign a decision is right.&lt;/p&gt;

&lt;p&gt;One more case in that &lt;code&gt;when&lt;/code&gt;: &lt;code&gt;PressInteraction.Cancel&lt;/code&gt; shares the release path. A press that turns into a scroll emits &lt;code&gt;Cancel&lt;/code&gt;, not &lt;code&gt;Release&lt;/code&gt;, and if you only handle &lt;code&gt;Release&lt;/code&gt; the element stays shrunk permanently. That's a bug you find on a list, not on a demo screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;graphicsLayer&lt;/code&gt;, and why the lambda form
&lt;/h2&gt;

&lt;p&gt;Two separate traps live here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't animate size.&lt;/strong&gt; The tempting alternative is to shrink the element for real — animate a &lt;code&gt;padding&lt;/code&gt;, a &lt;code&gt;heightIn&lt;/code&gt;, a &lt;code&gt;requiredSize&lt;/code&gt;. Do that and the &lt;em&gt;measured&lt;/em&gt; bounds shrink with the visual, which means the touch target shrinks mid-gesture. FormaUI enforces a 48dp minimum on every button; an animation that quietly violates it during the exact moment the user is touching it would be a genuinely bad accessibility bug, and it would never show up in a screenshot test. &lt;code&gt;graphicsLayer&lt;/code&gt; transforms at draw time, so the measured size — and the 48dp floor — is untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;buttonModifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heightIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaButtonDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MinTouchTargetSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 48.dp, measured&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formaPressScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pressedScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pressAnimationSpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// draw-time only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Read the animated value inside the lambda.&lt;/strong&gt; These two lines are not equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;graphicsLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scaleX&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scaleY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// recomposes every frame&lt;/span&gt;
&lt;span class="nf"&gt;graphicsLayer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;scaleX&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;scaleY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// draw-time read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first reads &lt;code&gt;scale.value&lt;/code&gt; during composition, so every frame of the animation invalidates the composable. The second defers the read into the layer block, which runs at draw — so a 60fps animation costs zero recompositions. The lambda overload of &lt;code&gt;graphicsLayer&lt;/code&gt; exists for precisely this, and it's a free win on any animated transform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observe-only, which is the harder API decision
&lt;/h2&gt;

&lt;p&gt;The modifier takes an &lt;code&gt;InteractionSource&lt;/code&gt;, not a &lt;code&gt;MutableInteractionSource&lt;/code&gt;, and it never detects input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formaPressScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InteractionSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pressedScale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaPressScaleDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PressedScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;downAnimationSpec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FiniteAnimationSpec&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaPressScaleDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DownAnimationSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;animationSpec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FiniteAnimationSpec&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormaPressScaleDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AnimationSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It watches someone else's press interactions. The caller has to hand it the &lt;em&gt;same&lt;/em&gt; source the element's &lt;code&gt;clickable&lt;/code&gt; emits into — the pattern &lt;code&gt;Modifier.indication&lt;/code&gt; already established:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;interactionSource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;MutableInteractionSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Modifier&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formaPressScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clickable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interactionSource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indication&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ripple&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detecting presses inside the modifier would have been a friendlier API — one argument fewer, nothing to wire up. It would also mean two independent gesture detectors on the same element, racing the ripple and double-counting a press. Observe-only is the correct decision, and it's worth being honest that it comes with the worst class of failure mode: pass two different sources and you get &lt;strong&gt;silence&lt;/strong&gt;, not an error.&lt;/p&gt;

&lt;p&gt;Inside a component you own, the wiring is one line and invisible to your users — &lt;code&gt;FormaButton&lt;/code&gt; remembers a source, hands it to &lt;code&gt;formaPressScale&lt;/code&gt;, and passes the same one down to the underlying M3 button. Exposing the modifier publicly means exposing the footgun; I'd rather ship the reusable primitive and document the trap than hide it and re-implement the animation in five components.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;animationSpec = null&lt;/code&gt; escape hatch returns the receiver unchanged, so disabling it adds no modifier nodes at all rather than a node that animates to 1.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing an animation nothing can see
&lt;/h2&gt;

&lt;p&gt;Here's the part that connects to &lt;a href="https://www.formaui.dev/blog/compose-canvas-tests-are-lying-to-you" rel="noopener noreferrer"&gt;an earlier post about Canvas tests&lt;/a&gt;: the scale lives in a &lt;code&gt;graphicsLayer&lt;/code&gt;. It is not in the semantics tree. There is no assertion that reads it. You cannot test the value.&lt;/p&gt;

&lt;p&gt;So don't test the value — test the &lt;strong&gt;regression class&lt;/strong&gt;. The bug was "a fast tap skips the dip," which means the thing to reproduce is the interleaving, not the pixels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;quickTap_playsFullDipAndSettles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// …setContent with a clickable Box carrying formaPressScale…&lt;/span&gt;

    &lt;span class="c1"&gt;// Pause the clock so Release is guaranteed to arrive before the dip has&lt;/span&gt;
    &lt;span class="c1"&gt;// played a single frame — the exact interleaving the old version broke on.&lt;/span&gt;
    &lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mainClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;autoAdvance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;pressable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;performTouchInput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;up&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mainClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advanceTimeBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mainClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;autoAdvance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;pressable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runOnIdle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// The queue must be fully drained and interactive again — this is what&lt;/span&gt;
    &lt;span class="c1"&gt;// would wedge if sequential collection misbehaved.&lt;/span&gt;
    &lt;span class="n"&gt;pressable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;performClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runOnIdle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mainClock.autoAdvance = false&lt;/code&gt; is what makes this a real test rather than a race. With the clock paused, &lt;code&gt;down()&lt;/code&gt; and &lt;code&gt;up()&lt;/code&gt; both land before any animation frame runs — a harsher interleaving than a human hand can produce, and precisely the one that used to fail.&lt;/p&gt;

&lt;p&gt;The assertions are indirect on purpose. Still displayed, exactly one click, and — the important one — &lt;strong&gt;still interactive afterwards&lt;/strong&gt;. A sequential collector that wedged, or an infinite spec that never completed, would block the flow forever and the second &lt;code&gt;performClick()&lt;/code&gt; would fail. That's the real failure mode being guarded, and it's observable even though the animation isn't.&lt;/p&gt;

&lt;p&gt;The companion test for the disable path leans on identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runOnIdle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;animationSpec == null&lt;/code&gt; returns the receiver unchanged, and the receiver was &lt;code&gt;Modifier&lt;/code&gt; itself, then identity equality proves no node was added. One assertion, no inspection of internals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feedback about an event is not a function of state.&lt;/strong&gt; If a piece of motion must complete regardless of how quickly the triggering state reverts, &lt;code&gt;animateFloatAsState&lt;/code&gt; is the wrong tool and no spec tuning will save it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;collect&lt;/code&gt; gives you sequencing for free.&lt;/strong&gt; A suspending collector body plus a suspending &lt;code&gt;animateTo&lt;/code&gt; is a queue. You don't need a state machine or a minimum-duration timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split interruptible motion from uninterruptible motion.&lt;/strong&gt; If one phase blocks the next, make that phase short, fixed, and boring; spend the expressiveness on the phase nothing is waiting for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle &lt;code&gt;Cancel&lt;/code&gt;, not just &lt;code&gt;Release&lt;/code&gt;.&lt;/strong&gt; Scrolling a list over your component is the common path, not the edge case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transform at draw time, never at measure time&lt;/strong&gt;, or your touch target animates along with your visual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read animated values inside the &lt;code&gt;graphicsLayer&lt;/code&gt; lambda&lt;/strong&gt;, not as arguments to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the thing you changed isn't observable, test the failure it caused.&lt;/strong&gt; Pause the clock, force the bad interleaving, and assert the component is still alive on the other side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final implementation is about thirty lines, five of the forty components use it, and it took considerably longer to get right than "scale the button a bit when you press it" has any business taking.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/devsnackio/forma-ui" rel="noopener noreferrer"&gt;FormaUI&lt;/a&gt; is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done. &lt;code&gt;Modifier.formaPressScale&lt;/code&gt; is public API, so you can put the interaction on your own components; it's on by default for &lt;a href="https://www.formaui.dev/components/button" rel="noopener noreferrer"&gt;buttons&lt;/a&gt;, &lt;a href="https://www.formaui.dev/components/icon-button" rel="noopener noreferrer"&gt;icon buttons&lt;/a&gt;, &lt;a href="https://www.formaui.dev/components/card" rel="noopener noreferrer"&gt;cards&lt;/a&gt;, &lt;a href="https://www.formaui.dev/components/chip" rel="noopener noreferrer"&gt;chips&lt;/a&gt; and &lt;a href="https://www.formaui.dev/components/floating-action-button" rel="noopener noreferrer"&gt;FABs&lt;/a&gt;. &lt;a href="https://www.formaui.dev/components" rel="noopener noreferrer"&gt;Try them live in your browser&lt;/a&gt; — the dip is easier to feel than to read about.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>animation</category>
      <category>programming</category>
    </item>
    <item>
      <title>Docs that run the real component</title>
      <dc:creator>DevSnack</dc:creator>
      <pubDate>Mon, 10 Aug 2026 14:37:01 +0000</pubDate>
      <link>https://dev.to/devsnack/docs-that-run-the-real-component-2omi</link>
      <guid>https://dev.to/devsnack/docs-that-run-the-real-component-2omi</guid>
      <description>&lt;p&gt;Documenting a UI library has three bad answers. Screenshots go stale by the next release. Videos don't let anyone interact. A separate demo app doesn't get installed, because the whole point of evaluating a library is that you haven't committed to it yet.&lt;/p&gt;

&lt;p&gt;What you actually want is for the reader to click the button. Not a picture of the button.&lt;/p&gt;

&lt;p&gt;So every component page on this site runs the real component. Same Kotlin source that compiles to the Android artifact, compiled a second time for the browser, rendering live on the page you're reading. Change a variant, disable it, flip it to dark — you're driving real Compose code, not a CSS class swap.&lt;/p&gt;

&lt;p&gt;Here's how it works, what it cost, and the parts that were more awkward than they should have been.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compiling the library twice
&lt;/h2&gt;

&lt;p&gt;FormaUI ships to Android and only Android. But Compose has a &lt;code&gt;wasmJs&lt;/code&gt; target, and the components are plain Compose, so the same source compiles for the browser too. That second compilation exists purely to build these docs. It isn't a platform you can ship FormaUI to, and I want to be unambiguous about that before "runs in the browser" reads as a support claim.&lt;/p&gt;

&lt;p&gt;Compose on the web doesn't compile to DOM elements — it renders through Skia onto a single canvas. Your &lt;code&gt;Column&lt;/code&gt; isn't a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;; it's pixels, drawn by the same rendering path that draws them on a phone.&lt;/p&gt;

&lt;p&gt;For a normal web app that's mostly a downside: no DOM means no browser text selection, a weaker accessibility story than real HTML, and a large runtime download before anything appears. For &lt;em&gt;previewing a component library&lt;/em&gt; it's exactly right, because fidelity is the entire product. A DOM reimplementation of these components would be a second codebase that could drift, and the moment it drifts the preview is a lie. Rendering through Skia means what you see is what compiles to the artifact — bug for bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  One bundle, not forty
&lt;/h2&gt;

&lt;p&gt;This is the decision that made the whole thing viable.&lt;/p&gt;

&lt;p&gt;The naive approach is one wasm build per component. It's a disaster: Skia's runtime is 8.3 MB and it would be in every single one of them. Instead there's a single parameterised entry point that reads a query parameter and looks the component up in a registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;requestedId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;componentQueryParam&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nc"&gt;DefaultComponentId&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PreviewRegistry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;requestedId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s"&gt;"formaui-preview:$requestedId"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s"&gt;"formaui-preview:unknown:$requestedId"&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty pages, one runtime, downloaded once and cached across every page a reader visits. Adding a component to the docs is adding an entry to &lt;code&gt;PreviewRegistry&lt;/code&gt; — no new build target, no new bundle.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;document.title&lt;/code&gt; line is doing quiet work too: it means an end-to-end test can assert &lt;em&gt;which&lt;/em&gt; component actually rendered inside the canvas, which is otherwise opaque from the outside. When your UI is pixels on a canvas, the usual DOM queries have nothing to grab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The controls are HTML. The component is canvas.
&lt;/h2&gt;

&lt;p&gt;The bit I didn't expect to build.&lt;/p&gt;

&lt;p&gt;Each doc page has a control bar — variant tabs, a disabled toggle, a light/dark pill. The obvious approach is to draw those inside the canvas, since that's where the component lives. That's wrong: canvas controls aren't keyboard-navigable in the way the rest of the page is, they don't inherit the site's styling, and they're invisible to a screen reader.&lt;/p&gt;

&lt;p&gt;So the docs page draws the controls in its own DOM, as ordinary accessible HTML, and drives the component through them over &lt;code&gt;postMessage&lt;/code&gt;. State lands in a couple of holders created before composition starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;controls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PreviewControls&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;hostDark&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;(&lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;installPreviewControlBridge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestedId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;update&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decodePreviewControls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;hostDark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing Compose state from a JS event callback looks alarming and isn't, for a reason specific to this target: &lt;strong&gt;wasm is single-threaded here&lt;/strong&gt;, so the write lands on the same thread the recomposer observes and the global snapshot picks it up like any other state write. On Android the same pattern would need thought about which thread you're on. Here there is only one.&lt;/p&gt;

&lt;p&gt;Two details in that snippet are load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capability is announced, not assumed.&lt;/strong&gt; Each registry entry declares which controls it honours, and the harness reports that to the embedder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;announcePreviewControls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;component&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requestedId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;variants&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joinToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ControlFieldSeparator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;supportsEnabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supportsEnabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs page renders tabs and the disabled toggle &lt;strong&gt;only&lt;/strong&gt; for what's declared. So a control can never appear for a preview that would silently ignore it — the failure mode where a user clicks something and nothing happens is designed out rather than tested for. The variant lists are derived from the Kotlin enums rather than written by hand, so a new variant in the library can't go missing from the docs tab row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hostDark&lt;/code&gt; starts null, and null means something.&lt;/strong&gt; Not "light" — &lt;em&gt;"no host is driving theme."&lt;/em&gt; While it's null the preview keeps its own in-canvas light/dark switch on screen. The moment a host sends a theme, that switch steps aside rather than sitting next to the page's theme pill doing the same job. The same principle applies to every host-driven value: they default to inert, so opening a bundle directly — no iframe, no docs site — still renders the full self-contained preview it always did.&lt;/p&gt;

&lt;p&gt;That's a rule worth generalising. An embeddable thing that only works when embedded is much harder to debug than one that degrades to standing alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the bundle to the build
&lt;/h2&gt;

&lt;p&gt;The docs site is a separate repo from the library, and Vercel checks out only the docs site. It has no Gradle, no Kotlin toolchain, and no access to a build of the library.&lt;/p&gt;

&lt;p&gt;So the library's CI publishes &lt;code&gt;previews-&amp;lt;version&amp;gt;.tar.gz&lt;/code&gt; as a release asset, and the site's &lt;code&gt;prebuild&lt;/code&gt; step resolves it from one of two places: a sibling checkout of the library repo when developing locally, or that release asset when building on Vercel. The tarball layout is a cross-repo contract — changing it means changing the workflow that produces it, the script that consumes it, and the test that guards both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing bundle fails the build, loudly and on purpose.&lt;/strong&gt; My first instinct was to make it a warning so builds would never break. That was wrong: a warning means you ship a docs site where every preview is a "not available" card, and you find out from a user. There's an explicit opt-out flag for the rare case where shipping without previews is genuinely what you want. Loud by default, quiet on request.&lt;/p&gt;

&lt;p&gt;The assembled bundle lands in a directory named for a content hash of its own contents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public/previews/b3483432795a/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A changed bundle always gets a new URL, so everything under that path can be cached forever — &lt;code&gt;immutable&lt;/code&gt;, a year — including the entry HTML. Nothing ever needs revalidating, because a new bundle is reached by a new URL rather than by busting an old one. For a 39 MB payload that distinction matters more than it usually would.&lt;/p&gt;

&lt;h2&gt;
  
  
  About that 39 MB
&lt;/h2&gt;

&lt;p&gt;Let's not bury it. The current bundles are &lt;strong&gt;27.9 MB of wasm plus 8.3 MB of Skia&lt;/strong&gt;, because they're development builds. That's a lot of bytes for a docs page.&lt;/p&gt;

&lt;p&gt;Four things make it survivable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is eager.&lt;/strong&gt; Previews load on click or when an &lt;code&gt;IntersectionObserver&lt;/code&gt; says they've scrolled into view — never on page load, and never on the component index, where forty of them would otherwise be in the DOM at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's paid once per visitor,&lt;/strong&gt; not once per component, because of the single-bundle decision above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's cached immutably,&lt;/strong&gt; because of the content-hashed path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of it reaches a consuming app.&lt;/strong&gt; This is a docs artifact. The Android dependency is an ordinary Compose library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Release builds would be substantially smaller and I haven't done that work yet. I'd still rather ship an honest 39 MB preview than a fast screenshot that's lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fonts don't come for free.&lt;/strong&gt; The theme uses Public Sans, and on wasm that means the compiled resources have to ship alongside the wasm output so fonts load at runtime. Miss it and you get a preview that renders in a fallback font — which, for a library whose pitch is partly &lt;em&gt;"the typography is better"&lt;/em&gt;, is the worst possible failure mode. It looks fine. It's just wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser support is narrower than you'd guess.&lt;/strong&gt; The bundle needs WasmGC &lt;em&gt;and&lt;/em&gt; JS string builtins — recent-Chromium-class, not "modern browsers." The check is pure feature detection with no network calls, so it can run before the bundle exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPreviewSupported&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;WebAssembly&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hasGc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasJsStringBuiltins&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;gc&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;jsStringBuiltins&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hasGc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hasJsStringBuiltins&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unsupported browsers get a card that says so plainly. I decided against a screenshot fallback, because a screenshot silently standing in for a live preview is exactly the stale-screenshot problem this was built to escape.&lt;/p&gt;

&lt;p&gt;One more, since it's easy to skip: the message listener checks the sender's origin. A same-origin &lt;code&gt;postMessage&lt;/code&gt; from anywhere else on the site shouldn't be able to spoof preview state. It's a docs site, the stakes are low, and it's still four lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Yes, for a reason I didn't anticipate: it changed what I'm able to say.&lt;/p&gt;

&lt;p&gt;The pitch for an opinionated component library is &lt;em&gt;"this looks good, so you don't have to make it look good."&lt;/em&gt; That's a claim about aesthetics, which means every word written in support of it is worth approximately nothing. Nobody believes "beautiful defaults," and they're right not to.&lt;/p&gt;

&lt;p&gt;Live previews replace the claim with the thing. A reader doesn't evaluate my adjectives — they click the button, drag the slider, flip to dark, and decide for themselves in about four seconds. It's the shortest possible distance between "I'm reading about a library" and "I know whether I want this."&lt;/p&gt;

&lt;p&gt;That's also the honest test of an opinionated library, and I'd rather fail it in public than win an argument about it in a README.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/devsnackio/forma-ui" rel="noopener noreferrer"&gt;FormaUI&lt;/a&gt; is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done. &lt;a href="https://www.formaui.dev/components" rel="noopener noreferrer"&gt;Try every one of them live&lt;/a&gt;, which is the entire point of the last two thousand words.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>webassembly</category>
      <category>webdev</category>
      <category>android</category>
    </item>
    <item>
      <title>Your Compose Canvas tests are lying to you</title>
      <dc:creator>DevSnack</dc:creator>
      <pubDate>Mon, 10 Aug 2026 14:35:23 +0000</pubDate>
      <link>https://dev.to/devsnack/your-compose-canvas-tests-are-lying-to-you-55hi</link>
      <guid>https://dev.to/devsnack/your-compose-canvas-tests-are-lying-to-you-55hi</guid>
      <description>&lt;p&gt;A user opened an issue against FormaUI: the bar chart rendered nothing. Not an error, not a crash — a correctly-sized, perfectly empty rectangle where the chart should be. The layout was right. The space was reserved. Nothing was painted.&lt;/p&gt;

&lt;p&gt;The uncomfortable part wasn't the bug. It was that the chart had tests, the tests were green, and they had been green the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests were actually asserting
&lt;/h2&gt;

&lt;p&gt;The chart's test suite looked reasonable. It rendered the component, found it by its semantics, and checked the things you check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;composeTestRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onNodeWithContentDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bar chart with 4 categories…"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertExists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertWidthIsAtLeast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those passed while the component drew nothing at all.&lt;/p&gt;

&lt;p&gt;They passed because &lt;strong&gt;semantics and pixels are two different trees&lt;/strong&gt;. &lt;code&gt;assertExists&lt;/code&gt; asks whether a node is in the semantics tree. &lt;code&gt;assertIsDisplayed&lt;/code&gt; asks whether that node's layout bounds intersect the visible window. &lt;code&gt;assertWidthIsAtLeast&lt;/code&gt; asks about layout bounds again. Not one of them asks the only question that matters for a &lt;code&gt;Canvas&lt;/code&gt; component: &lt;em&gt;did anything get drawn?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a &lt;code&gt;Button&lt;/code&gt;, this distinction rarely bites, because a button that lays out correctly almost always draws correctly — its rendering is Material's problem, not yours. For anything you paint yourself, the gap is wide enough to drive a release through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual bug
&lt;/h2&gt;

&lt;p&gt;Here's the code that caused it, reduced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;drawBars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. It isn't, in the general case — and the reason is worth internalising: &lt;strong&gt;&lt;code&gt;Canvas&lt;/code&gt; is a &lt;code&gt;Spacer&lt;/code&gt; under the hood&lt;/strong&gt;, and its measure policy reports zero for any dimension that isn't fixed. Give it a modifier chain where height resolves to a constraint rather than a concrete value and &lt;code&gt;DrawScope.size&lt;/code&gt; comes back with a zero dimension, so every draw call is a no-op against an empty area. Meanwhile the &lt;em&gt;layout&lt;/em&gt; slot is exactly the size you asked for, because the parent's constraints filled it in.&lt;/p&gt;

&lt;p&gt;That is the precise shape of a bug semantics assertions cannot see. Layout bounds: plausible. Draw size: zero. Test suite: green.&lt;/p&gt;

&lt;p&gt;The fix is to separate the two concerns — let a &lt;code&gt;Box&lt;/code&gt; own sizing and semantics, and let the &lt;code&gt;Canvas&lt;/code&gt; fill it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;semantics&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchParentSize&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;drawBars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But fixing the bug is the easy half. The question that actually mattered was: &lt;em&gt;how do I write a test that would have caught this?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;captureToImage()&lt;/code&gt; doesn't work under Robolectric
&lt;/h2&gt;

&lt;p&gt;The obvious answer is pixel assertions. Compose ships &lt;code&gt;captureToImage()&lt;/code&gt; for exactly this, and FormaUI's UI tests run on Robolectric — fast, on the JVM, no emulator. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;composeTestRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chart"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;captureToImage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ComposeTimeoutException: Condition still not satisfied after 2000 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time. Every component. Not just charts — a plain &lt;code&gt;Text&lt;/code&gt; does the same thing. Changing &lt;code&gt;GraphicsMode&lt;/code&gt;, setting &lt;code&gt;robolectric.pixelCopyRenderMode&lt;/code&gt;, waiting for idle, none of it helps.&lt;/p&gt;

&lt;p&gt;The reason is structural rather than a configuration mistake. &lt;code&gt;captureToImage()&lt;/code&gt; captures the &lt;strong&gt;window&lt;/strong&gt;, and before it does, it runs a &lt;code&gt;forceRedraw&lt;/code&gt; step: it registers a frame-commit / &lt;code&gt;onDraw&lt;/code&gt; callback and waits for a frame-driven draw pass to fire it. Under Robolectric there is no frame loop. Compose's own source says so, in &lt;code&gt;RobolectricIdlingStrategy&lt;/code&gt; — &lt;em&gt;"Draw passes don't happen."&lt;/em&gt; So the callback never fires, the wait always expires, and you get a timeout that looks like a flake and isn't one.&lt;/p&gt;

&lt;p&gt;No amount of retrying fixes a callback that is never going to be called.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling &lt;code&gt;View.draw&lt;/code&gt; directly
&lt;/h2&gt;

&lt;p&gt;If the problem is that nothing will &lt;em&gt;schedule&lt;/em&gt; a draw pass, the answer is to stop waiting for one and dispatch it yourself. &lt;code&gt;View.draw(Canvas)&lt;/code&gt; renders synchronously, right now, on the calling thread — no frame loop involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;SemanticsNodeInteraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureNodeToImage&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;ImageBitmap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;node&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetchSemanticsNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to capture the node to a bitmap."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;view&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;ViewRootForTest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;
    &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"Cannot capture: host view has degenerate size ${view.width}x${view.height}."&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fullBitmap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ARGB_8888&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fullBitmap&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bounds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;boundsInRoot&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;left&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;roundToInt&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;coerceIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;top&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;roundToInt&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;coerceIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;width&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;roundToInt&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;coerceAtMost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;height&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;roundToInt&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;coerceAtMost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fullBitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;asImageBitmap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get the host &lt;code&gt;View&lt;/code&gt; from the semantics node's root, draw it into a software bitmap, crop to the node's bounds. Thirty lines, no new dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap underneath the trap
&lt;/h2&gt;

&lt;p&gt;That still returned blank bitmaps.&lt;/p&gt;

&lt;p&gt;Robolectric has two graphics backends. The default in many projects — including this one — is &lt;strong&gt;LEGACY&lt;/strong&gt;, the shadow implementation, where &lt;code&gt;drawPath&lt;/code&gt;, &lt;code&gt;drawArc&lt;/code&gt; and &lt;code&gt;drawLine&lt;/code&gt; are raster no-ops and rectangle fills rasterize as hairline outlines. Your capture is honest; there genuinely is nothing in the bitmap, because the drawing operations quietly did nothing.&lt;/p&gt;

&lt;p&gt;This one is worth verifying yourself rather than believing me, because it invalidates a whole category of test. Take Compose out of the picture entirely, allocate an &lt;code&gt;android.graphics.Canvas&lt;/code&gt;, draw a filled rect and a path onto it, and read the pixels back. Under LEGACY they aren't there.&lt;/p&gt;

&lt;p&gt;The fix is one annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RunWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RobolectricTestRunner&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@GraphicsMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GraphicsMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NATIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sdk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChartPixelRenderTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NATIVE mode rasterizes every operation correctly using the &lt;code&gt;org.robolectric:nativeruntime&lt;/code&gt; artifacts, which Robolectric has probably already cached. Tests run marginally slower. The bitmaps contain what was drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the test finally looks like
&lt;/h2&gt;

&lt;p&gt;With capture working, the assertion is almost embarrassingly simple — and that's the point. It doesn't compare against a golden image, because golden images for charts are a maintenance tax that pays out mostly in false failures on font-rendering changes. It just asserts that the component painted &lt;em&gt;something&lt;/em&gt; other than the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;barChart_actuallyPaintsPixels&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;composeTestRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;FormaTheme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;FormaBarChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sampleEntries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;animationSpec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;composeTestRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chart"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;captureNodeToImage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Chart region contains only background pixels — nothing was drawn."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasNonBackgroundPixels&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;animationSpec = null&lt;/code&gt;. The charts animate on entry, so under test you want a deterministic final frame rather than a race against an 800ms tween. If your canvas component animates and doesn't offer a way to switch that off, that's a testability gap worth closing — you'll want it for screenshots too.&lt;/p&gt;

&lt;p&gt;This assertion is deliberately weak. It cannot tell a correct chart from a wrong one. What it &lt;em&gt;can&lt;/em&gt; tell you is the difference between a chart and an empty rectangle, which is the failure that actually shipped, survived a green test suite, and was found by a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;If a component paints its own pixels — charts, sparklines, progress rings, signature pads, custom dividers, anything holding a &lt;code&gt;Canvas&lt;/code&gt; or a &lt;code&gt;drawBehind&lt;/code&gt; — &lt;strong&gt;your semantics assertions are describing a component you have not verified renders.&lt;/strong&gt; They're not worthless; they're just answering a different question than the one you think you asked.&lt;/p&gt;

&lt;p&gt;The bar to hold for those components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Assert on pixels, not only on semantics.&lt;/li&gt;
&lt;li&gt;Don't reach for &lt;code&gt;captureToImage()&lt;/code&gt; on Robolectric — it cannot work there, and the timeout will read as flakiness for as long as you let it.&lt;/li&gt;
&lt;li&gt;Draw the host &lt;code&gt;View&lt;/code&gt; into a bitmap yourself and crop to the node.&lt;/li&gt;
&lt;li&gt;Run those tests in &lt;code&gt;GraphicsMode.NATIVE&lt;/code&gt;, or your capture will be blank and you'll trust a blank capture.&lt;/li&gt;
&lt;li&gt;Keep the assertion coarse. "Something was drawn" catches the bug that ships. Golden images mostly catch font updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;FormaUI's charts now carry eight pixel-render tests built on that helper. They'd have caught the original bug in about a second — which is roughly how long it took a user to spot it, and considerably less time than it took me to work out why the obvious test infrastructure couldn't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/devsnackio/forma-ui" rel="noopener noreferrer"&gt;FormaUI&lt;/a&gt; is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done, including bar, line and donut charts with no third-party chart dependency. &lt;a href="https://www.formaui.dev/components" rel="noopener noreferrer"&gt;Try every component live in your browser&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>testing</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Track MRR on Lemon Squeezy (Accurately) in 2026</title>
      <dc:creator>DevSnack</dc:creator>
      <pubDate>Sun, 05 Jul 2026 02:29:47 +0000</pubDate>
      <link>https://dev.to/devsnack/how-to-track-mrr-on-lemon-squeezy-accurately-in-2026-246</link>
      <guid>https://dev.to/devsnack/how-to-track-mrr-on-lemon-squeezy-accurately-in-2026-246</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://throughlines.site/blog/how-to-track-mrr-on-lemon-squeezy?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Throughlines blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.lemonsqueezy.com" rel="noopener noreferrer"&gt;Lemon Squeezy&lt;/a&gt; is a great place to start selling — it's a merchant of record, so tax and compliance are handled for you. But once subscriptions start rolling in, one question gets harder than it should: &lt;strong&gt;what's my actual MRR, and can I trust the chart?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's how MRR works on Lemon Squeezy, the gotcha most people miss, and how to track it accurately — especially if you also sell on Polar or Paddle.&lt;/p&gt;

&lt;h2&gt;
  
  
  MRR = monthly recurring revenue, normalized
&lt;/h2&gt;

&lt;p&gt;MRR is the recurring revenue you can expect each month from active subscriptions. The math is simple in principle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A $20/mo plan contributes &lt;strong&gt;$20&lt;/strong&gt; to MRR.&lt;/li&gt;
&lt;li&gt;A $240/year plan contributes &lt;strong&gt;$240 ÷ 12 = $20&lt;/strong&gt; to MRR (you normalize annual to monthly).&lt;/li&gt;
&lt;li&gt;Sum that across all active subscriptions = your MRR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tricky part isn't the arithmetic. It's &lt;strong&gt;movement&lt;/strong&gt; — how MRR changes over time — and &lt;strong&gt;what counts as a subscription ending.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lemon Squeezy gotcha: a snapshot isn't a history
&lt;/h2&gt;

&lt;p&gt;Lemon Squeezy's built-in MRR/ARR has improved. Historically the charts were calculated in real time from the subscriptions active &lt;em&gt;right now&lt;/em&gt;, which meant historical metrics weren't precise — they didn't properly account for subscriptions that were active in the past but have since cancelled. Lemon Squeezy now takes a &lt;strong&gt;daily snapshot&lt;/strong&gt; of MRR/ARR to keep historical data accurate.&lt;/p&gt;

&lt;p&gt;That's a real improvement, but it highlights the core issue: &lt;strong&gt;MRR is a story over time, not a single live number.&lt;/strong&gt; To analyze trends, compare months, or explain a dip, you need a ledger of every change — not just today's total.&lt;/p&gt;

&lt;h2&gt;
  
  
  What accurate MRR tracking actually requires
&lt;/h2&gt;

&lt;p&gt;To track MRR on Lemon Squeezy properly, you need to capture each event that changes recurring revenue and classify it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Movement&lt;/th&gt;
&lt;th&gt;Triggered by&lt;/th&gt;
&lt;th&gt;Effect on MRR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;New&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;First active subscription&lt;/td&gt;
&lt;td&gt;+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Expansion&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upgrade to a higher-priced plan&lt;/td&gt;
&lt;td&gt;+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Contraction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Downgrade to a lower-priced plan&lt;/td&gt;
&lt;td&gt;−&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Churn&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Subscription actually ends / access revoked&lt;/td&gt;
&lt;td&gt;−&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reactivation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A churned customer resubscribes&lt;/td&gt;
&lt;td&gt;+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things people consistently get wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Annual normalization.&lt;/strong&gt; If you don't divide annual plans by 12, your MRR balloons every time someone buys a yearly plan, then looks like it crashes. Always normalize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canceled ≠ churned.&lt;/strong&gt; This is the big one (next section).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The mistake that wrecks your churn number
&lt;/h2&gt;

&lt;p&gt;On Lemon Squeezy, a subscription can be &lt;em&gt;cancelled&lt;/em&gt; but still active until the end of the paid period. That customer is &lt;strong&gt;still paying you.&lt;/strong&gt; If your tracking counts that cancellation as churn immediately, you'll:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overstate churn.&lt;/li&gt;
&lt;li&gt;Record the revenue loss too early.&lt;/li&gt;
&lt;li&gt;Panic about a retention problem that hasn't happened yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Churn should only be recorded when the subscription &lt;strong&gt;actually ends&lt;/strong&gt; and access is revoked (in Lemon Squeezy terms, when it expires/ends rather than when &lt;code&gt;cancelled&lt;/code&gt; is simply flagged true). And if a customer reverses a scheduled cancellation, that pending flag should just clear — no movement at all.&lt;/p&gt;

&lt;p&gt;Lemon Squeezy webhooks also don't ship the per-cycle price and interval inline on every event, so accurate tracking means looking up the related product variant to get the real recurring amount. It's the kind of detail that's easy to miss in a hand-rolled spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to track it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lemon Squeezy's built-in charts.&lt;/strong&gt; Good for a quick glance now that snapshots exist. Limited for deep trend analysis, and it only shows Lemon Squeezy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spreadsheet from CSV exports.&lt;/strong&gt; Total control, total maintenance. You'll own the normalization and churn logic forever, and it won't update live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A purpose-built MoR dashboard.&lt;/strong&gt; Connect once, get correct movement automatically, and — crucially — combine Lemon Squeezy with your other platforms.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Tracking it accurately with Throughlines
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://throughlines.site?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Throughlines&lt;/a&gt; is a read-only dashboard built for merchant-of-record platforms, with Lemon Squeezy as a first-class integration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connect read-only.&lt;/strong&gt; Paste your Lemon Squeezy API key; Throughlines backfills history and stays current via webhooks. It never modifies your store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correct movement, automatically.&lt;/strong&gt; Annual normalized to monthly; new/expansion/contraction/churn/reactivation classified for you; variant lookups handled so per-cycle pricing is right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canceled ≠ churned, enforced.&lt;/strong&gt; Pending cancellations keep counting until access is actually revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy cross-check.&lt;/strong&gt; Throughlines reconciles its computed MRR against Lemon Squeezy's own figure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One number across platforms.&lt;/strong&gt; Selling on Polar or Paddle too? See your consolidated MRR in a single view.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Lemon Squeezy's snapshot-based MRR is a solid glance, but accurate tracking means a proper ledger: normalize annual plans, classify every movement, and never count a scheduled cancellation as churn. Do that by hand and it's a chore that drifts out of sync; do it with a tool built for MoR platforms and you get numbers you can actually quote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://throughlines.site?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Track your Lemon Squeezy MRR in Throughlines →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related reading: &lt;a href="https://throughlines.site/blog/why-canceled-isnt-churned?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Why "canceled" isn't "churned"&lt;/a&gt; · &lt;a href="https://throughlines.site/blog/consolidating-mrr-across-mor-platforms?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Consolidating MRR across MoR platforms&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>analytics</category>
      <category>startup</category>
    </item>
    <item>
      <title>Why 'Canceled' Isn't 'Churned' (and How It Wrecks Your Metrics)</title>
      <dc:creator>DevSnack</dc:creator>
      <pubDate>Sun, 05 Jul 2026 02:25:21 +0000</pubDate>
      <link>https://dev.to/devsnack/why-canceled-isnt-churned-and-how-it-wrecks-your-metrics-2d97</link>
      <guid>https://dev.to/devsnack/why-canceled-isnt-churned-and-how-it-wrecks-your-metrics-2d97</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://throughlines.site/blog/why-canceled-isnt-churned?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Throughlines blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's the single most common mistake in subscription analytics, and it quietly poisons the most important retention number you have. &lt;strong&gt;A cancellation is not churn.&lt;/strong&gt; Treat them as the same thing and your churn rate becomes both inaccurate and early.&lt;/p&gt;

&lt;p&gt;Here's the distinction, why it matters more than it sounds, and how to get it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The difference in one line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Canceled (scheduled cancellation):&lt;/strong&gt; the customer has turned off auto-renew, but they're &lt;strong&gt;still paying&lt;/strong&gt; through the end of the current period. They still have access. MRR is unchanged &lt;em&gt;today&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Churned:&lt;/strong&gt; the subscription has &lt;strong&gt;actually ended&lt;/strong&gt; — the period lapsed, access was revoked, the money stopped. &lt;em&gt;This&lt;/em&gt; is when MRR drops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A customer who cancels on the 3rd but is paid through the 30th has not churned. They might even change their mind before the 30th. Counting them as churned on the 3rd is simply wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this wrecks your metrics
&lt;/h2&gt;

&lt;p&gt;If you record churn at the moment of cancellation instead of the moment the subscription ends, three things break:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your churn rate is overstated.&lt;/strong&gt; You're counting revenue as lost while it's still being collected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your timing is wrong.&lt;/strong&gt; Losses show up days or weeks early, smearing your monthly cohorts and making trend analysis unreliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You react to phantom problems.&lt;/strong&gt; You see a churn spike, scramble to fix "retention," and the customers in question were never actually gone — some renew or un-cancel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Worse, the reverse error hides churn: if you only look at "active" subscriptions and never model the &lt;em&gt;transition&lt;/em&gt; out of paying, you can miss churn entirely when access lapses silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correct model: track the transition, not the flag
&lt;/h2&gt;

&lt;p&gt;The reliable approach treats a subscription's &lt;strong&gt;state&lt;/strong&gt;, not a single boolean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A subscription is &lt;strong&gt;"paying"&lt;/strong&gt; when it's &lt;code&gt;active&lt;/code&gt; &lt;strong&gt;or&lt;/strong&gt; &lt;code&gt;pending_cancellation&lt;/code&gt; (scheduled to cancel but still within the paid period).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Churn fires only on the transition &lt;em&gt;out&lt;/em&gt; of paying&lt;/strong&gt; — when the state moves to &lt;code&gt;ended&lt;/code&gt; / access revoked.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;reversal&lt;/strong&gt; of a scheduled cancellation just clears the pending flag. No MRR movement, no churn, no expansion — nothing moved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In ledger terms: a scheduled cancellation produces &lt;strong&gt;no delta&lt;/strong&gt;. Only the actual end produces the negative churn delta. This is the rule that keeps churn honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the platforms express it
&lt;/h2&gt;

&lt;p&gt;Each merchant-of-record platform encodes this differently, which is part of why hand-rolled tracking gets it wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Polar&lt;/strong&gt; distinguishes &lt;code&gt;subscription.canceled&lt;/code&gt; (still paying → pending, &lt;em&gt;not&lt;/em&gt; churn) from &lt;code&gt;subscription.revoked&lt;/code&gt; (access revoked → churn).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lemon Squeezy&lt;/strong&gt; can have &lt;code&gt;cancelled = true&lt;/code&gt; while the subscription is still active until period-end; churn is when it actually expires/ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle&lt;/strong&gt; signals scheduled cancellations distinctly from the subscription actually ending.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your spreadsheet just watches for the word "cancel," it will count all three platforms wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick gut check
&lt;/h2&gt;

&lt;p&gt;Ask yourself: &lt;em&gt;"Is this customer still paying me right now?"&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes&lt;/strong&gt; → not churn, even if they've scheduled a cancellation. Flag it as pending and watch it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No, access has ended&lt;/strong&gt; → that's churn. Record the MRR loss now.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Throughlines handles it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://throughlines.site?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Throughlines&lt;/a&gt; enforces canceled ≠ churned as a core rule, identically across every platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It tracks each subscription's normalized state and only records churn on the transition out of paying.&lt;/li&gt;
&lt;li&gt;A scheduled cancellation is flagged as pending and keeps contributing to MRR until access is actually revoked.&lt;/li&gt;
&lt;li&gt;Un-canceling clears the pending flag with zero movement.&lt;/li&gt;
&lt;li&gt;The result shows up in a clear MRR-movement waterfall, so you can see real new/expansion/contraction/churn/reactivation — not phantom churn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the same logic runs across Polar, Paddle, and Lemon Squeezy, your churn number means the same thing no matter where the customer pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;"Canceled" is an intention; "churned" is an outcome. Record churn only when the subscription actually ends — and treat scheduled cancellations as pending revenue that's still yours until proven otherwise. Get this one rule right and your retention metrics finally tell the truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://throughlines.site?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;See honest churn across all your platforms →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related reading: &lt;a href="https://throughlines.site/blog/how-to-track-mrr-on-lemon-squeezy?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;How to track MRR on Lemon Squeezy&lt;/a&gt; · &lt;a href="https://throughlines.site/blog/consolidating-mrr-across-mor-platforms?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Consolidating MRR across MoR platforms&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>analytics</category>
      <category>metrics</category>
    </item>
    <item>
      <title>Consolidating MRR Across Merchant-of-Record Platforms (Polar, Paddle, Lemon Squeezy)</title>
      <dc:creator>DevSnack</dc:creator>
      <pubDate>Tue, 16 Jun 2026 14:48:27 +0000</pubDate>
      <link>https://dev.to/devsnack/consolidating-mrr-across-merchant-of-record-platforms-polar-paddle-lemon-squeezy-43bg</link>
      <guid>https://dev.to/devsnack/consolidating-mrr-across-merchant-of-record-platforms-polar-paddle-lemon-squeezy-43bg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://throughlines.site/blog/consolidating-mrr-across-mor-platforms?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Throughlines blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A few years ago, "what's our MRR?" had one answer in one place. Now a growing number of founders sell through &lt;strong&gt;multiple merchant-of-record (MoR) platforms&lt;/strong&gt; — &lt;a href="https://polar.sh" rel="noopener noreferrer"&gt;Polar&lt;/a&gt;, &lt;a href="https://www.paddle.com" rel="noopener noreferrer"&gt;Paddle&lt;/a&gt;, &lt;a href="https://www.lemonsqueezy.com" rel="noopener noreferrer"&gt;Lemon Squeezy&lt;/a&gt; — and the question suddenly has two or three partial answers, none of which is the whole truth.&lt;/p&gt;

&lt;p&gt;This is the consolidation problem. Here's why it happens, why it's harder than it looks, and how to solve it cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why founders end up on multiple MoRs
&lt;/h2&gt;

&lt;p&gt;Each platform has a sweet spot, so spreading out is rational:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lemon Squeezy&lt;/strong&gt; is a fast, friendly place to start selling — popular with solo creators and indie hackers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polar&lt;/strong&gt; is developer-first, with strong APIs and room to scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle&lt;/strong&gt; brings deeper subscription and tax tooling that tends to matter once you're past ~$10K MRR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you start on one, add another for a specific product or region, and now your revenue is genuinely split. Each platform handles its own sales, tax, and payouts as a merchant of record — which is great operationally, but it means your numbers are siloed by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why incumbents don't solve it
&lt;/h2&gt;

&lt;p&gt;The established analytics tools — Baremetrics, ChartMogul — were built around Stripe. Their integration lists reflect that: Stripe, Braintree, Recurly, Chargebee, the App Stores, and (for some) Paddle. &lt;strong&gt;Polar and Lemon Squeezy are barely covered, if at all.&lt;/strong&gt; So the moment you adopt a newer MoR, the Stripe-era tools can't even see half your revenue, let alone consolidate it.&lt;/p&gt;

&lt;p&gt;That leaves most multi-MoR founders doing it by hand: open each dashboard, copy each MRR, add them in a spreadsheet, repeat monthly. It's tedious and — as we'll see — often wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why naive consolidation is wrong
&lt;/h2&gt;

&lt;p&gt;Adding two or three MRR numbers seems trivial. It isn't, because the inputs aren't consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different movement definitions.&lt;/strong&gt; Each platform classifies new/expansion/contraction/churn its own way. Summing totals hides what actually changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Annual normalization.&lt;/strong&gt; If annual plans aren't all divided by 12 the same way, every yearly sale distorts the combined figure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency and rounding.&lt;/strong&gt; Mixed currencies and per-platform rounding make the sum drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot timing.&lt;/strong&gt; Platforms snapshot on different schedules; "today's MRR" isn't the same instant everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Churn timing.&lt;/strong&gt; If one platform counts a scheduled cancellation as churn and another doesn't, your blended churn is meaningless.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix isn't better spreadsheet hygiene. It's a different architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clean architecture: normalize first, then sum
&lt;/h2&gt;

&lt;p&gt;Consolidation works when you translate &lt;strong&gt;every event from every platform into one internal format before doing any math&lt;/strong&gt; — a ports-and-adapters approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Adapters&lt;/strong&gt; read each platform (Polar, Paddle, Lemon Squeezy) and emit a &lt;strong&gt;normalized event&lt;/strong&gt;: a status plus a monthly-normalized amount in a single currency. No platform-specific quirks leak past this boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One classifier&lt;/strong&gt; turns state changes into signed movements — new, expansion, contraction, churn, reactivation — using identical rules everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One append-only MRR ledger&lt;/strong&gt; holds every signed delta. Your MRR on any date is the sum of deltas up to that date; movement for a period is the deltas grouped by type.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two invariants tell you it's correct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Combined MRR = the sum of each platform's MRR.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Each platform's trend line sums to the combined trend line.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When those hold, the consolidated number isn't a guess — it's reconciled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good consolidation gives you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One MRR number&lt;/strong&gt; for the whole business, with month-over-month change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A per-platform breakdown&lt;/strong&gt; (stacked area over time) so you can see who's growing and who's leaking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A combined movement waterfall&lt;/strong&gt; across all platforms at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blended metrics&lt;/strong&gt; — churn, ARPU, net revenue retention — computed over your entire customer base, not one silo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-platform accuracy checks&lt;/strong&gt; so each connection is validated against its own reported figure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Throughlines does it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://throughlines.site?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Throughlines&lt;/a&gt; is built for this exact problem. It's a read-only revenue dashboard for merchant-of-record platforms, architected for &lt;em&gt;N&lt;/em&gt; platforms from day one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connect each platform read-only&lt;/strong&gt; (paste a token/key per platform). Throughlines backfills history and stays live via webhooks, and never writes to your accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consolidated MRR hero&lt;/strong&gt; across Polar, Paddle, and Lemon Squeezy, with the delta vs. last month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-platform stacked area + combined movement waterfall&lt;/strong&gt;, reconciled so the parts always sum to the whole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canceled ≠ churned, enforced identically&lt;/strong&gt; across every platform, so blended churn is honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy gate per connection.&lt;/strong&gt; Each platform's computed MRR is cross-checked against the platform's own number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a platform, not a migration.&lt;/strong&gt; A fourth MoR is a new connection, not a re-tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If your revenue is spread across Polar, Paddle, and Lemon Squeezy, your true MRR isn't in any one dashboard — and you can't get it by adding numbers in a spreadsheet, because the inputs aren't consistent. Consolidation done right means normalizing every platform into one ledger so the combined number reconciles. That single, trustworthy number is the whole reason merchant-of-record-native analytics exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://throughlines.site?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Consolidate your MRR across every platform →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related reading: &lt;a href="https://throughlines.site/blog/paddle-and-polar-revenue-in-one-dashboard?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Paddle + Polar revenue in one dashboard&lt;/a&gt; · &lt;a href="https://throughlines.site/blog/why-canceled-isnt-churned?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;Why "canceled" isn't "churned"&lt;/a&gt; · &lt;a href="https://throughlines.site/blog/baremetrics-alternative-for-polar?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-launch" rel="noopener noreferrer"&gt;The best Baremetrics alternative for Polar&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>startup</category>
      <category>analytics</category>
    </item>
  </channel>
</rss>
