<?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: Ján Turský</title>
    <description>The latest articles on DEV Community by Ján Turský (@jn_tursk_0fd72ffe51c3cc).</description>
    <link>https://dev.to/jn_tursk_0fd72ffe51c3cc</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%2F1647855%2Fdd52d8d0-ce14-4670-82a9-369f700e6958.jpg</url>
      <title>DEV Community: Ján Turský</title>
      <link>https://dev.to/jn_tursk_0fd72ffe51c3cc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jn_tursk_0fd72ffe51c3cc"/>
    <language>en</language>
    <item>
      <title>I asked two phones where my office is. They did not agree.</title>
      <dc:creator>Ján Turský</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:20:05 +0000</pubDate>
      <link>https://dev.to/jn_tursk_0fd72ffe51c3cc/i-asked-two-phones-where-my-office-is-they-did-not-agree-16bm</link>
      <guid>https://dev.to/jn_tursk_0fd72ffe51c3cc/i-asked-two-phones-where-my-office-is-they-did-not-agree-16bm</guid>
      <description>&lt;p&gt;I asked two phones where my office is located. &lt;strong&gt;They did not agree.&lt;/strong&gt; One of them also thinks it's an account problem.&lt;/p&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you ask, no, I don't have an office.&lt;/strong&gt; I just wanted to check if a tiny prompt survives a model update, and the office question happend to be case number twelve. That's it. That's the whole origin story.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qjdlgr219glbsm020zm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qjdlgr219glbsm020zm.png" alt="Drake meme: Apple Foundation Models says Account, Gemini Nano says Other" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;




&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "experiment" (I use that word loosely)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What did I do?&lt;/strong&gt; I wrote a support ticket classifier. Five labels: Billing, Bug, Account, Feature, Other. Twelve tickets, each one run three times, temperature 0.2, exact match against the expected label. &lt;strong&gt;That's the whole experiment.&lt;/strong&gt; (Source: Trust me bro, but also the JSON files, they are public.)&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;suite&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evalSuite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ticket-classifier"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"v1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;"Classify this support ticket as one of Billing, Bug, Account, Feature or Other. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
            &lt;span class="s"&gt;"Reply with the category only.\n\nTicket: $input"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"My card was charged twice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"double-charge"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Billing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Where is your office located?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"office"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Other"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;graders&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;exactMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ignoreCase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;passWhen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&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 suite lives in &lt;code&gt;commonMain&lt;/code&gt;, and the runner is a small library I built called &lt;strong&gt;Proctor&lt;/strong&gt;. It runs the same suite against the model that is already inside the phone and writes one JSON file per run. Gemini Nano on Android (through ML Kit), Apple Foundation Models on iOS.&lt;/p&gt;

&lt;p&gt;I tested on a Pixel 11 Pro with all four Gemini Nano 4 variants you can pick in AICore: stable and preview, full and fast. For iOS I used the simulator, which borrows the Mac's Foundation Models. &lt;strong&gt;Yes, simulator. I'll come back to that, don't worry.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;




&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  Android is boring (and that's good)
&lt;/h2&gt;

&lt;p&gt;Twelve out of twelve tickets pass on nearly every run, about 140 ms per call. That includes the office question, which gets &lt;code&gt;Other&lt;/code&gt; three times out of three. The only wobble in eight classifier runs was preview/fast on the first prompt, which missed one case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boring is exactly what you want from a classifier.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96ssj1n7nf7u4m7xdb26.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96ssj1n7nf7u4m7xdb26.png" alt="Sample app on the Pixel 11 Pro with the results" width="545" height="1217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;




&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  iOS has opinions
&lt;/h2&gt;

&lt;p&gt;On iOS the office ticket fails. Three attempts, three answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expected "Other", got "Account"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It fails with both prompts.&lt;/strong&gt; The second prompt even adds a short definition per category (Billing is charges, refunds, invoices. Account is login, password, profile) and the iOS answer didn't move at all. Mean score was 92% on the first prompt and 97% on the second, one failed case each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Apple's model file an office location under Account?&lt;/strong&gt; I have no idea. My guess is that "your" and "located" sounds like it's about the company details, but that's a guess, not a finding. What I can tell you is that it's very stable. Same answer every time, about 335 ms.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8uee032zytqoa5xljjg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8uee032zytqoa5xljjg.png" alt="Case detail in the sample app, passed and failed cases" width="560" height="1217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the thing that I find interesting. &lt;strong&gt;Both phones are 11/12 or better.&lt;/strong&gt; If you test it by hand with five tickets you would never notice. You only see it because the exact same twelve tickets ran on both.&lt;/p&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;




&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  "Valid JSON" is not the same as "correct"
&lt;/h2&gt;

&lt;p&gt;The second suite pulls three fields out of a ticket as JSON: order id, product, urgent. One ticket says &lt;em&gt;"I cannot log in to the web dashboard, blocking my whole team right now."&lt;/em&gt; and the expected product is &lt;code&gt;web dashboard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every Gemini Nano variant and the iOS model answer with &lt;code&gt;"product": null&lt;/code&gt;. Nobody finds the dashboard. &lt;strong&gt;That's a real model miss, fair enough.&lt;/strong&gt; Three of four cases pass on Android, two of four on iOS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftj56l1x3089nlchr6lzp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftj56l1x3089nlchr6lzp.png" alt="This is fine meme: jsonSchema grader passed, product is null" width="799" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But look at what Android actually returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;```json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"urgent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;```&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini Nano wraps the JSON in a Markdown code fence, the iOS model returns bare JSON. The &lt;code&gt;jsonSchema&lt;/code&gt; grader passed on both (it strips the fences before decoding) and the field check &lt;code&gt;detailsMatch&lt;/code&gt; is the one that failed with &lt;em&gt;"product null instead of web dashboard"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two graders, two different opinions about the same output.&lt;/strong&gt; If I only checked "is it valid JSON?" I would have a perfect score and a bug in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9u2qrh8zffibbna21sxt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9u2qrh8zffibbna21sxt.png" alt="Case detail for the failed extraction case on the Pixel 11 Pro" width="545" height="1217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;




&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  So why bother?
&lt;/h2&gt;

&lt;p&gt;The system model is not part of your app. &lt;strong&gt;The OS updates it, not you.&lt;/strong&gt; Different phones run different versions, and the model that ships next autumn is not the one you tested. Google's own ML Kit docs recommend running an evaluation dataset per Gemini Nano version, and then give you no on-device tool for it. Apple has an Evaluations framework, but for Apple platforms only.&lt;/p&gt;

&lt;p&gt;So the check I wanted was small: run the same cases on every model I can reach, keep the JSON, diff it later. &lt;strong&gt;Nothing fancy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I have NOT verified
&lt;/h2&gt;

&lt;p&gt;Because someone in the comments will ask, so lets do it first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS ran on a &lt;strong&gt;simulator&lt;/strong&gt; with the Mac's Foundation Models. No real iPhone with Apple Intelligence was involved. &lt;strong&gt;And yes, I do own an iPhone 17e&lt;/strong&gt;, so you would think this is easy. It's not. The iPhones sold in my country (Europe) come with a model number that says the phone was made in China, and for that variant the Apple Intelligence feature is locked. So the one iPhone I have can't run the model I wanted to test. Sadly. If you have an iPhone with Apple Intelligence working and want to run the same suite, I would really like to see your numbers.&lt;/li&gt;
&lt;li&gt;Twelve tickets and four extraction cases is a smoke test, not a benchmark. Three repeats at low temperature shows stability, not coverage.&lt;/li&gt;
&lt;li&gt;One device, one Android build. Another phone may ship a different Nano.&lt;/li&gt;
&lt;li&gt;The sample report labels runs by model version, so stable and preview of the same model looks the same in one table. I know. It's on my list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;




&lt;p&gt;&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Proctor 1.0.0 is on Maven Central (&lt;code&gt;io.github.jantursky:proctor&lt;/code&gt;, plus &lt;code&gt;proctor-mlkit&lt;/code&gt; for Android and &lt;code&gt;proctor-ios&lt;/code&gt; for iOS), MIT licensed. The sample report from the runs above is open, so you can look at every case without installing anything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/jantursky/proctor" rel="noopener noreferrer"&gt;https://github.com/jantursky/proctor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sample report: &lt;a href="https://proctor-demo.vercel.app" rel="noopener noreferrer"&gt;https://proctor-demo.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://proctor-docs-phi.vercel.app" rel="noopener noreferrer"&gt;https://proctor-docs-phi.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Did a model update ever change the output of your Prompt API feature?&lt;/strong&gt; Tell me what moved, I'm curious.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>android</category>
      <category>ios</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Made Compose UI Look Printed: A Dither Engine in commonMain (Shaders Optional)</title>
      <dc:creator>Ján Turský</dc:creator>
      <pubDate>Wed, 28 Oct 2026 19:24:33 +0000</pubDate>
      <link>https://dev.to/jn_tursk_0fd72ffe51c3cc/i-made-compose-ui-look-printed-a-dither-engine-in-commonmain-shaders-optional-1eg9</link>
      <guid>https://dev.to/jn_tursk_0fd72ffe51c3cc/i-made-compose-ui-look-printed-a-dither-engine-in-commonmain-shaders-optional-1eg9</guid>
      <description>&lt;p&gt;I wanted UI that looks &lt;strong&gt;printed&lt;/strong&gt;. 🖨️ A dither turns a smooth tone into a pattern of ink and paper dots, and I built a small library that does it for Compose Multiplatform: &lt;strong&gt;Stipple&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fjantursky%2Fstipple%2Fmain%2F.github%2Fassets%2Fengine-light.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fjantursky%2Fstipple%2Fmain%2F.github%2Fassets%2Fengine-light.gif" alt="A spiral field turns, two renderers draw the same ripples side by side, and a modifier dithers a box" width="560" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;It runs on Android, iOS, desktop and the browser from one &lt;code&gt;commonMain&lt;/code&gt;. &lt;a href="https://stipple-demo.vercel.app" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt;, &lt;a href="https://github.com/jantursky/stipple" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤔 Why
&lt;/h2&gt;

&lt;p&gt;I noticed how popular dither is in web development, and I like that kind of niche visual style. There were dithered components for React, and a few Compose experiments, but I couldn't find a small &lt;strong&gt;engine&lt;/strong&gt; in common code that you can aim at &lt;em&gt;any&lt;/em&gt; composable.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧩 Three swappable pieces
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;field&lt;/strong&gt; says how much ink each point wants (0 is paper, 1 is ink),&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;pattern&lt;/strong&gt; decides which cells turn on (Bayer 2/4/8, blue noise or your own),&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;theme&lt;/strong&gt; holds the colours and the cell size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A field is a pure function, so a new look is one lambda:&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;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;spiral&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DitherField&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aspect&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;dx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;aspect&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5f&lt;/span&gt;
    &lt;span class="mf"&gt;0.5f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;14f&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;2f&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;2f&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;size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;240&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;dither&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spiral&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DitherPatterns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Bayer4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🧱 Two renderers, one picture
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;cell renderer&lt;/strong&gt; works everywhere, back to Android 6. It samples the field once per cell and merges neighbouring cells of one colour into a single rectangle. The &lt;strong&gt;shader renderer&lt;/strong&gt; draws the whole element in one fragment shader (AGSL on Android 13+, a Skia runtime effect elsewhere) and falls back to cells where it can't run.&lt;/p&gt;

&lt;h2&gt;
  
  
  🐛 The hash that wouldn't port
&lt;/h2&gt;

&lt;p&gt;To draw the &lt;code&gt;noise&lt;/code&gt; field in the shader I had to port its hash. My CPU hash used 32-bit integer overflow and shifts, and AGSL has no reliable version of those. So I switched to a float-only hash and made the cell renderer use it too, so both renderers draw exactly the same noise. The catch, straight from the changelog: &lt;em&gt;which cells light up for a given noise seed changes; the look does not.&lt;/em&gt; Golden screenshots had to be regenerated in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  ♿ Reduced motion
&lt;/h2&gt;

&lt;p&gt;Stipple reads the platform's reduced-motion setting and freezes animations on a still frame. Progress and values keep updating, because a progress bar that stops moving hides information. Only decoration freezes.&lt;/p&gt;

&lt;h2&gt;
  
  
  🙈 What I have NOT verified
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;the shader renderer on iOS (the cell renderer and gallery are checked on an iPhone 17 simulator, no physical iPhone),&lt;/li&gt;
&lt;li&gt;Android 6 to 12 on a real device (Robolectric only), and other Android 13+ phones than my Samsung on Android 15,&lt;/li&gt;
&lt;li&gt;Firefox and Safari itself (Chromium and WebKit through Playwright),&lt;/li&gt;
&lt;li&gt;frame times on low-end Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🚀 Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;commonMain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.github.jantursky:stipple:1.1.0"&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 you have an iPhone, the most useful thing you can do is open the gallery and tell me whether the shader renderer draws correctly. Anything else, tell me in the comments. 🙂&lt;/p&gt;

&lt;p&gt;&lt;a href="https://stipple-demo.vercel.app" rel="noopener noreferrer"&gt;Gallery&lt;/a&gt; · &lt;a href="https://stipple-docs.vercel.app" rel="noopener noreferrer"&gt;Docs&lt;/a&gt; · &lt;a href="https://github.com/jantursky/stipple" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; (MIT)&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>android</category>
      <category>compose</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
