<?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: Kazunori Osaki</title>
    <description>The latest articles on DEV Community by Kazunori Osaki (@kosaki08).</description>
    <link>https://dev.to/kosaki08</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2739701%2Fe05ee6d1-a07c-4ab8-8df6-9dede144eab8.jpeg</url>
      <title>DEV Community: Kazunori Osaki</title>
      <link>https://dev.to/kosaki08</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kosaki08"/>
    <language>en</language>
    <item>
      <title>Tired of eyeballing Figma vs Storybook? Here’s how I gate design fidelity in CI</title>
      <dc:creator>Kazunori Osaki</dc:creator>
      <pubDate>Tue, 18 Nov 2025 15:30:55 +0000</pubDate>
      <link>https://dev.to/kosaki08/uimatch-figma-to-implementation-visual-diff-with-playwright-and-ci-1819</link>
      <guid>https://dev.to/kosaki08/uimatch-figma-to-implementation-visual-diff-with-playwright-and-ci-1819</guid>
      <description>&lt;p&gt;I got tired of eyeballing Figma vs Storybook on every PR and trying to remember:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Is this actually what the designer meant?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I built &lt;strong&gt;uiMatch&lt;/strong&gt; – a CLI that compares a Figma frame directly with your implementation (Storybook iframe or any URL), generates diff images, and gives you a 0–100 "Design Fidelity Score" you can gate in CI.&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.amazonaws.com%2Fuploads%2Farticles%2Ffvfufffy9ba1kvt2ierq.jpg" 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.amazonaws.com%2Fuploads%2Farticles%2Ffvfufffy9ba1kvt2ierq.jpg" alt="Spot the difference" width="700" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  By the end of this post, you'll have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Automated Figma vs Storybook comparison running locally&lt;/li&gt;
&lt;li&gt;A clear pass/fail quality gate you can understand and tune&lt;/li&gt;
&lt;li&gt;CI that blocks PRs when designs drift too far&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All in about 15 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fflfezqlg22os0ihxfff3.gif" 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.amazonaws.com%2Fuploads%2Farticles%2Fflfezqlg22os0ihxfff3.gif" alt="Demo" width="800" height="580"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Run your first comparison locally
&lt;/h2&gt;

&lt;p&gt;First, install uiMatch and Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install globally&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @uimatch/cli playwright
npx playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium

&lt;span class="c"&gt;# or as a dev dependency&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; @uimatch/cli playwright
npx playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Export your Figma token. uiMatch CLI doesn't load &lt;code&gt;.env&lt;/code&gt; files automatically, so it has to be in the environment. Get one from &lt;a href="https://www.figma.com/developers/api#access-tokens" rel="noopener noreferrer"&gt;Figma Settings → Personal access tokens&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;FIGMA_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;figd_xxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run a minimal comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx -p @uimatch/cli uimatch compare \
  figma=FILE_KEY:NODE_ID \
  story=http://localhost:6006/iframe.html?id=button--primary \
  selector="#root button" \
  outDir=./uimatch-reports \
  profile=component/strict
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; I recommend using &lt;code&gt;FILE_KEY:NODE_ID&lt;/code&gt; format, but if you pass a full Figma URL, quote it in the shell (e.g., &lt;code&gt;figma='https://www.figma.com/...'&lt;/code&gt;) so &lt;code&gt;?&lt;/code&gt; and &lt;code&gt;&amp;amp;&lt;/code&gt; don't get split into separate arguments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetches the Figma frame via the Figma REST API&lt;/li&gt;
&lt;li&gt;Captures your implementation using Playwright (Chromium)&lt;/li&gt;
&lt;li&gt;Compares them using the &lt;code&gt;component/strict&lt;/code&gt; profile&lt;/li&gt;
&lt;li&gt;Saves &lt;code&gt;figma.png&lt;/code&gt;, &lt;code&gt;impl.png&lt;/code&gt;, &lt;code&gt;diff.png&lt;/code&gt;, and &lt;code&gt;report.json&lt;/code&gt; into &lt;code&gt;./uimatch-reports&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What the output looks like
&lt;/h3&gt;

&lt;p&gt;In the screenshots below:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Figma&lt;/strong&gt; uses &lt;code&gt;uiMatch&lt;/code&gt; with a dark button&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation&lt;/strong&gt; uses &lt;code&gt;UI Match&lt;/code&gt; with a green button&lt;/li&gt;
&lt;li&gt;uiMatch detects the color / layout / text differences and highlights them in red on the diff&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Figma&lt;/th&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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.amazonaws.com%2Fuploads%2Farticles%2F0vjfhno86l2snykdp7n6.png" alt="Figma" width="800" height="236"&gt;&lt;/td&gt;
&lt;td&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.amazonaws.com%2Fuploads%2Farticles%2Fxt17s91glvvc6ismj7g7.png" alt="Implementation: React.js" width="800" height="236"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Diff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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.amazonaws.com%2Fuploads%2Farticles%2Fg54j6mpblk83y6d66wf4.png" alt="Diff" width="800" height="236"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Visually, the implementation still "looks right", but the diff + metrics make it obvious where it deviates and whether it passes the quality gate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Understanding the report (DFS + quality gate)
&lt;/h2&gt;

&lt;p&gt;uiMatch writes a JSON report that looks roughly like:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metrics"&lt;/span&gt;&lt;span class="p"&gt;:&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;"pixelDiffRatio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0583&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"colorDeltaEAvg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dfs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;97&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;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&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;"figma"&lt;/span&gt;&lt;span class="p"&gt;:&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;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1892&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;560&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;"impl"&lt;/span&gt;&lt;span class="p"&gt;:&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;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1892&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;560&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;"compared"&lt;/span&gt;&lt;span class="p"&gt;:&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;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1892&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;560&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;"sizeMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pad"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"adjusted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;"qualityGate"&lt;/span&gt;&lt;span class="p"&gt;:&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;"pass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cqi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cqiBreakdown"&lt;/span&gt;&lt;span class="p"&gt;:&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;"components"&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pixel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rawValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0583&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"threshold"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"penalty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"weight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.6&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;span class="err"&gt;...&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;"totalPenalty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"baseScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&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;"reasons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"pixelDiffRatio 5.83% &amp;gt; 1.00%"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"thresholds"&lt;/span&gt;&lt;span class="p"&gt;:&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;"pixelDiffRatio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"deltaE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&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="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"styleDiffs"&lt;/span&gt;&lt;span class="p"&gt;:&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;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&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;"figmaAutoRoi"&lt;/span&gt;&lt;span class="p"&gt;:&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;"applied"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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="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;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;other&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;fields&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;omitted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;brevity&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two key fields for CI:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;metrics.dfs&lt;/code&gt;&lt;/strong&gt;: A 0–100 "how close did we get?" score that blends pixel, color, and layout signals&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;qualityGate.pass&lt;/code&gt;&lt;/strong&gt;: A strict pass/fail decision based on the active profile's thresholds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice you'll usually just read those two fields and ignore most of the rest in CI.&lt;/p&gt;

&lt;p&gt;That's why you can see &lt;code&gt;dfs: 97&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;qualityGate.pass: false&lt;/code&gt; at the same time:&lt;br&gt;
the overall fidelity is high, but the current profile (e.g., &lt;code&gt;component/strict&lt;/code&gt; with &lt;code&gt;pixelDiffRatio &amp;lt;= 1%&lt;/code&gt;) still decides "this is above the allowed diff, so fail the gate".&lt;/p&gt;
&lt;h3&gt;
  
  
  Choosing the right profile
&lt;/h3&gt;

&lt;p&gt;uiMatch ships with a few built-in quality gate profiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;component/strict   &lt;span class="c"&gt;# pixelDiffRatio: 0.01 (1%), deltaE: 3.0&lt;/span&gt;
&lt;span class="nv"&gt;profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;component/dev      &lt;span class="c"&gt;# pixelDiffRatio: 0.08,       deltaE: 5.0&lt;/span&gt;
&lt;span class="nv"&gt;profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;page-vs-component  &lt;span class="c"&gt;# padded / letterboxed comparisons&lt;/span&gt;
&lt;span class="nv"&gt;profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;lenient            &lt;span class="c"&gt;# prototypes, rough drafts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on &lt;code&gt;component/strict&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
It’s intentionally very strict. Even with a visually “perfect” implementation,&lt;br&gt;
font rendering differences and anti-aliasing can easily produce &lt;strong&gt;2–3%&lt;/strong&gt; pixel differences.&lt;br&gt;
That’s normal.&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For day-to-day CI I’d default to &lt;code&gt;component/dev&lt;/code&gt; or &lt;code&gt;lenient&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;I reserve &lt;code&gt;component/strict&lt;/code&gt; for design-system components in controlled environments
(fixed fonts, consistent rendering stack, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 3: Wiring into CI (GitHub Actions)
&lt;/h2&gt;

&lt;p&gt;uiMatch is built as a plain CLI: it reads env vars, talks to the Figma API, runs Playwright, and writes a JSON report + PNGs. That means it slots nicely into CI.&lt;/p&gt;

&lt;p&gt;Here's a basic GitHub Actions workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uiMatch QA&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;compare&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install uiMatch + Playwright&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npm install -g @uimatch/cli playwright&lt;/span&gt;
          &lt;span class="s"&gt;npx playwright install --with-deps chromium&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run uiMatch&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;FIGMA_ACCESS_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.FIGMA_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npx -p @uimatch/cli uimatch compare \&lt;/span&gt;
            &lt;span class="s"&gt;figma=${{ secrets.FIGMA_FILE }}:${{ secrets.FIGMA_NODE }} \&lt;/span&gt;
            &lt;span class="s"&gt;story=https://your-storybook.com/iframe.html?id=button--primary \&lt;/span&gt;
            &lt;span class="s"&gt;selector="#root button" \&lt;/span&gt;
            &lt;span class="s"&gt;outDir=uimatch-reports \&lt;/span&gt;
            &lt;span class="s"&gt;profile=component/strict&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce quality gate&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;node - &amp;lt;&amp;lt;'EOF'&lt;/span&gt;
          &lt;span class="s"&gt;const fs = require('fs');&lt;/span&gt;
          &lt;span class="s"&gt;const report = JSON.parse(fs.readFileSync('uimatch-reports/report.json', 'utf8'));&lt;/span&gt;
          &lt;span class="s"&gt;const dfs = report.metrics?.dfs ?? 0;&lt;/span&gt;
          &lt;span class="s"&gt;const pass = report.qualityGate?.pass ?? false;&lt;/span&gt;

          &lt;span class="s"&gt;if (!pass) {&lt;/span&gt;
            &lt;span class="s"&gt;console.error(`❌ Quality gate failed (DFS=${dfs})`);&lt;/span&gt;
            &lt;span class="s"&gt;console.error(`Reasons: ${report.qualityGate?.reasons?.join(', ')}`);&lt;/span&gt;
            &lt;span class="s"&gt;process.exit(1);&lt;/span&gt;
          &lt;span class="s"&gt;}&lt;/span&gt;

          &lt;span class="s"&gt;console.log(`✅ Quality gate passed (DFS=${dfs})`);&lt;/span&gt;
          &lt;span class="s"&gt;EOF&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload artifacts&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uimatch-reports&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uimatch-reports/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;figma=...&lt;/code&gt;, &lt;code&gt;story=...&lt;/code&gt;, and &lt;code&gt;selector=...&lt;/code&gt; with your own setup.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ I'm still dogfooding this workflow myself, so treat it as a starting point rather than a battle-tested template.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Advanced: Text matching (catching &lt;code&gt;uiMatch&lt;/code&gt; vs &lt;code&gt;UI Match&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Pixel comparison is great, but copy/typo issues are easy to miss.&lt;/p&gt;

&lt;p&gt;uiMatch has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a separate &lt;strong&gt;&lt;code&gt;text-diff&lt;/code&gt;&lt;/strong&gt; subcommand&lt;/li&gt;
&lt;li&gt;an experimental &lt;strong&gt;text-matching mode&lt;/strong&gt; for &lt;code&gt;compare&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;text-diff&lt;/code&gt; CLI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx uimatch text-diff &lt;span class="s2"&gt;"Sign in"&lt;/span&gt; &lt;span class="s2"&gt;"SIGN  IN"&lt;/span&gt;
&lt;span class="c"&gt;# → kind: "whitespace-or-case-only", similarity: 1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It classifies differences into buckets like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;exact-match&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;whitespace-or-case-only&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;normalized-match&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mismatch&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and gives you a similarity score (0–1) so you can decide how picky you want to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text matching in &lt;code&gt;compare&lt;/code&gt; (experimental)
&lt;/h3&gt;

&lt;p&gt;You can also ask &lt;code&gt;compare&lt;/code&gt; to look at the text inside the target element and compare it to the Figma text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-p&lt;/span&gt; @uimatch/cli uimatch compare &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;figma&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FILE_KEY:NODE_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;story&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:6006/iframe.html?id&lt;span class="o"&gt;=&lt;/span&gt;hero &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[data-testid='hero']"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;textMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;descendants &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;outDir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./uimatch-reports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s what I use to catch things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;uiMatch&lt;/code&gt; vs &lt;code&gt;UI Match&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;View docs&lt;/code&gt; → &lt;code&gt;View on GitHub&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;accidental whitespace / casing issues&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The text diff results are included in &lt;code&gt;report.json&lt;/code&gt; alongside the pixel metrics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Advanced: Targeting the right element (&lt;code&gt;selector&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;selector&lt;/code&gt; you pass to uiMatch is forwarded straight to Playwright,&lt;br&gt;
so you can reuse whatever locators you already use in your tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Simple CSS&lt;/span&gt;
&lt;span class="nv"&gt;selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"#root button"&lt;/span&gt;

&lt;span class="c"&gt;# Data attributes / test IDs&lt;/span&gt;
&lt;span class="nv"&gt;selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[data-testid='hero']"&lt;/span&gt;

&lt;span class="c"&gt;# Playwright role / text selectors&lt;/span&gt;
&lt;span class="nv"&gt;selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"role=button[name='Sign in']"&lt;/span&gt;
&lt;span class="nv"&gt;selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"button:has-text('View docs')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means you don't need a second selector system just for visual diffing —&lt;br&gt;
you can piggyback on your existing Playwright setup.&lt;/p&gt;

&lt;p&gt;For more refactor-resistant targeting, uiMatch also has an experimental&lt;br&gt;
&lt;code&gt;@uimatch/selector-anchors&lt;/code&gt; plugin that resolves "anchors" in your code&lt;br&gt;
to real selectors via AST. I'll cover that in a separate post.&lt;/p&gt;




&lt;h2&gt;
  
  
  What uiMatch does under the hood
&lt;/h2&gt;

&lt;p&gt;For those curious about the implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Figma&lt;/strong&gt;: Fetch PNG via Figma REST API (or use cached PNG in CI)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation&lt;/strong&gt;: Capture with Playwright + grab computed styles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core engine&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/mapbox/pixelmatch" rel="noopener noreferrer"&gt;&lt;code&gt;pixelmatch&lt;/code&gt;&lt;/a&gt; for pixel diffs&lt;/li&gt;
&lt;li&gt;Perceptual color difference (ΔE2000) for color comparisons&lt;/li&gt;
&lt;li&gt;Multiple &lt;strong&gt;size modes&lt;/strong&gt;: &lt;code&gt;strict&lt;/code&gt;, &lt;code&gt;pad&lt;/code&gt;, &lt;code&gt;crop&lt;/code&gt;, &lt;code&gt;scale&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality gate profiles&lt;/strong&gt; with configurable thresholds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design Fidelity Score&lt;/strong&gt; (0–100) combining pixel, color, and layout signals&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;




&lt;h2&gt;
  
  
  Links &amp;amp; status
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/kosaki08/uimatch" rel="noopener noreferrer"&gt;https://github.com/kosaki08/uimatch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://kosaki08.github.io/uimatch/" rel="noopener noreferrer"&gt;https://kosaki08.github.io/uimatch/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s still &lt;strong&gt;0.x / experimental&lt;/strong&gt; and I’m mostly using it on Storybook setups right now.&lt;br&gt;
I’m very curious whether this kind of “Figma vs implementation in CI” flow would be useful to you, or if you’re solving it in a totally different way.&lt;/p&gt;

&lt;p&gt;If you have ideas, weird edge cases, or “this would only be useful if it also did X”, I’d love to hear them.&lt;/p&gt;

</description>
      <category>figma</category>
      <category>testing</category>
      <category>playwright</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
