<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thoughtbot="https://thoughtbot.com/feeds/" xmlns:feedpress="https://feed.press/xmlns" xmlns:media="http://search.yahoo.com/mrss/" xmlns:podcast="https://podcastindex.org/namespace/1.0">
  <feedpress:locale>en</feedpress:locale>
  <link rel="hub" href="https://feedpress.superfeedr.com/"/>
  <title>Giant Robots Smashing Into Other Giant Robots</title>
  <subtitle>Written by thoughtbot, your expert partner for design and development.
</subtitle>
  <id>https://robots.thoughtbot.com/</id>
  <link href="https://thoughtbot.com/blog"/>
  <link href="https://feed.thoughtbot.com/articles" rel="self"/>
  <updated>2026-05-28T00:00:00+00:00</updated>
  <author>
    <name>thoughtbot</name>
  </author>
  <entry>
    <title>This week in #dev (May 15, 2026)</title>
    <link rel="alternate" href="https://feed.thoughtbot.com/link/24106/17349820/this-week-in-dev-may-15-2026"/>
    <author>
      <name>thoughtbot</name>
    </author>
    <id>https://thoughtbot.com/blog/this-week-in-dev-may-15-2026</id>
    <published>2026-05-28T00:00:00+00:00</published>
    <updated>2026-05-26T14:17:29Z</updated>
    <content type="html"><![CDATA[<p>Welcome to another edition of <a href="https://thoughtbot.com/blog/tags/this-week-in-dev">This Week in #dev</a>, a series of posts
where we bring some of our most interesting Slack conversations to the public.</p>
<h2 id="alternative-text-for-css-generated-content">
  
    Alternative Text for CSS-Generated Content
  
</h2>

<p><a href="https://thoughtbot.com/blog/authors/matheus-richard">Matheus Richard</a> learned that the CSS <code>content</code> property accepts
alternative text for screen readers, separated by a <code>/</code>:</p>
<div class="highlight"><pre class="highlight css"><code><span class="nc">.warning</span><span class="nd">::before</span> <span class="p">{</span>
  <span class="nl">content</span><span class="p">:</span> <span class="s1">"⚠️"</span> <span class="o">/</span> <span class="s1">"Warning"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>
<p>Without the alt text, assistive technology either reads out the emoji name or
skips it entirely. More details in <a href="https://www.stefanjudis.com/today-i-learned/css-content-property-accepts-alternative-text/">Stefan Judis’ article</a>.</p>
<h2 id="a-faster-ui-for-large-github-diffs">
  
    A Faster UI for Large GitHub Diffs
  
</h2>

<p><a href="https://thoughtbot.com/blog/authors/matheus-richard">Matheus Richard</a> shares <a href="https://diffshub.com">diffshub</a>, a tool that renders PR
diffs GitHub struggles with. It’s a drop-in replacement: swap <code>github.com</code> for
<code>diffshub.com</code> in any PR URL, like
<a href="https://diffshub.com/oven-sh/bun/pull/30412">https://diffshub.com/oven-sh/bun/pull/30412</a>.</p>
<h2 id="aube-a-new-javascript-package-manager">
  
    Aube, a New JavaScript Package Manager
  
</h2>

<p><a href="https://thoughtbot.com/blog/authors/jared-turner">Jared Turner</a> shares <a href="https://aube.en.dev">Aube</a>, a JavaScript package manager
from the creator of Mise. It’s pitched as fast, compatible with existing
lockfiles, and security-focused, including a 24-hour cooldown before newly
published versions can be installed.</p>
<h2 id="thanks">
  
    Thanks
  
</h2>

<p>This edition was brought to you by <a href="https://thoughtbot.com/blog/authors/jared-turner">Jared Turner</a> and <a href="https://thoughtbot.com/blog/authors/matheus-richard">Matheus
Richard</a>. Thanks to all contributors! 🎉</p>

<aside class="related-articles"><h2>If you enjoyed this post, you might also like:</h2>
<ul>
<li><a href="https://thoughtbot.com/blog/this-week-in-dev-jan-26-2024">This Week in #dev (Jan 26, 2024)</a></li>
<li><a href="https://thoughtbot.com/blog/this-week-in-open-source-6-30">This Week in Open Source (June 30, 2023)</a></li>
<li><a href="https://thoughtbot.com/blog/this-week-in-dev-feb-9-2024">This Week in #dev (Feb 9, 2024)</a></li>
</ul></aside>
<img src="https://feed.thoughtbot.com/link/24106/17349820.gif" height="1" width="1"/>]]></content>
    <summary>Highlights of what happened in our #dev channel on Slack this week.
</summary>
    <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
  </entry>
  <entry>
    <title>Lost, forgotten, and unfamiliar HTML</title>
    <link rel="alternate" href="https://feed.thoughtbot.com/link/24106/17349023/lost-forgotten-and-unfamiliar-html"/>
    <author>
      <name>Dave Iverson</name>
    </author>
    <id>https://thoughtbot.com/blog/lost-forgotten-and-unfamiliar-html</id>
    <published>2026-05-27T00:00:00+00:00</published>
    <updated>2026-05-22T21:54:31Z</updated>
    <content type="html"><![CDATA[<p>I ran <a href="https://html-validate.org/">HTML-validate </a>and <a href="https://github.com/dequelabs/axe-core">Axe core</a> and a Claude prompt against a new website I’m building, and they caught a bunch of stuff I missed! This gave me a chance to remember the easily overlooked bits of building a website. And I visited a few dark corners of the HTML spec I hadn’t been to yet!</p>
<h2 id="data-attributes-should-be-lowercase">
  
    Data attributes should be lowercase
  
</h2>

<p><code>data-dialogOpen</code> is invalid - it should be <code>data-dialogopen</code>.
But did you know that <a href="https://html.spec.whatwg.org/multipage/dom.html#custom-data-attribute"><em>all</em> HTML attribute names get automatically lowercased</a>? I didn’t.</p>

<p>HTTP headers are also case-insensitive <a href="https://datatracker.ietf.org/doc/html/rfc9113#section-8.2.1">except in HTTP2</a> where they MUST be lowercase.</p>
<h2 id="invalid-id-attributes">
  
    Invalid id attributes
  
</h2>

<p>I learned that in HTML5, <a href="https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute">an <code>id</code> can be <em>anything</em> </a>as long as it’s 1 character with no whitespace (and it’s unique). <code>id="_0$!11"</code> is totally valid and I think even emojis are ok!.</p>

<p>However, <a href="https://www.w3.org/TR/html4/types.html#type-id">in HTML4 <code>,id</code>s need to start with a letter</a> and can only contain letters, numbers, and a few punctuation symbols. So it’s probably best not to go too wild. Backwards compatibility is nice.</p>

<p>Oh, and the uniqueness requirement? <code>id</code>s inside iFrames only need to be unique within their document. Otherwise, imagine how tricky it would be to iFrame in an arbitrary page.</p>
<h2 id="redundant-for-attributes">
  
    Redundant for attributes
  
</h2>

<p>A bit of a nitpick: when you label an input by putting it inside a label, the <code>for</code> attribute is redundant. When the input is outside the label, you definitely need that <code>for</code>!</p>
<div class="highlight"><pre class="highlight plaintext"><code>&lt;!-- Rails-style: no `for=""` needed --&gt;
&lt;label&gt;
  Username &lt;input type="text" name="username" /&gt;
&lt;/label&gt;

&lt;!-- non-Rails-style: don't forget the `for=""`! --&gt;
&lt;label for="username&gt;Username&lt;/label&gt;
&lt;input type="text" name="username" id="username" /&gt;
</code></pre></div>
<p>Some reasons that thoughtbot prefers inputs inside labels:</p>

<ul>
<li>it reduces the need for an extra wrapper div</li>
<li>since the label is clickable, this often results in a bigger click/tap area</li>
<li>you don’t need to generate unique IDs for inputs</li>
</ul>
<h2 id="extra-whitespace-in-a-textarea">
  
    Extra whitespace in a textarea
  
</h2>

<p>Claude spotted this one: I accidentally had a blank space inside a textarea.</p>
<div class="highlight"><pre class="highlight plaintext"><code>&lt;textarea name="explain"&gt; &lt;/textarea&gt;
</code></pre></div>
<p>An easy mistake to make and kind of annoying to an end user, especially because it will cause the <code>required</code> validation to be skipped. I wish one of my automated scanners had caught it.</p>
<h2 id="false-positive-aria-label-misuse">
  
    False positive: aria-label misuse
  
</h2>

<p>HTML-validate told me that using the <code>aria-label</code> attribute on <code>&lt;search&gt;</code> is invalid. Nope - I was using it correctly!</p>

<p><a href="https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/search.html">W3c explicitly recommends it</a>:</p>

<blockquote>
<p>If a page includes more than one search landmark, each should have a unique label.</p>
</blockquote>
<div class="highlight"><pre class="highlight plaintext"><code>&lt;search aria-label="Site-wide"&gt;
  &lt;form&gt;
    ...
  &lt;/form&gt;
&lt;/search&gt;

</code></pre></div>
<p><a href="https://gitlab.com/html-validate/html-validate/-/work_items/359">I filed a bug report.</a></p>
<h2 id="iframes-with-unique-names">
  
    iFrames with unique names
  
</h2>

<p>I had trouble with this one, but I’m glad Axe caught it because it’s genuinely useful for screen reader users.</p>

<p>Every iFrame needs a title, and those titles should be unique so they can be differentiated. But also, landmarks INSIDE the iFrames must be unique across the entire page, including the parent document.</p>

<p>I had 3 iFrames on a page, all with <code>&lt;main aria-label="Component Example"&gt;</code>. Sure enough, when I opened Voiceover it read out 3 of the same landmark:</p>

<ul>
<li>Component Example main</li>
<li>Component Example main</li>
<li>Component Example main</li>
</ul>

<p>That’s not a great experience.</p>

<p>First, I tried to fix it by removing the <code>aria-label</code>s, but Axe warns me that the document has multiple <code>&lt;main&gt;</code>s without unique labels. I had to refactor how the iFrames were generated so that each one had both a unique title and <code>&lt;main&gt;</code> label.</p>
<h2 id="color-contrast-issues">
  
    Color contrast issues
  
</h2>

<p>Automated scanners are the best at finding contrast issues. I happened to have a link state that used a slightly-too-light purple on white. It didn’t pass WCAG’s minimum contrast levels. Easy for me to miss, but troublesome for someone with reduced vision.</p>
<h2 id="keyboard-accessible-overflow-scrolling">
  
    Keyboard-accessible overflow scrolling
  
</h2>

<p>This was a new one for me! <a href="https://dequeuniversity.com/rules/axe/4.11/scrollable-region-focusable?application=playwright">Axe tells me</a> that when a region scrolls using <code>overflow: scroll</code> or similar, it must contain a focusable element. This seems to be a Safari-specific bug.</p>

<p>I tested with Safari and confirmed that it’s true: using the keyboard I was unable to scroll down to see the cut-off content.</p>

<p>The simplest solution is to add <code>tabindex="0"</code> to an element inside the scrolling region.</p>
<h2 id="forgotten-svgs">
  
    Forgotten SVGs
  
</h2>

<p>I’m constantly forgetting to check that SVGs have the right label and role. With images it’s easy: just make sure you’ve got an <code>alt</code> tag. But inline SVGs can either be decorative or presentational.</p>

<p>Decorative SVGs must use <code>aria-hidden="true"</code> to keep them out of the accessibility tree.</p>

<p>Presentational ones must use <code>role="image</code> and NEED a <code>&lt;title&gt;</code> tag to serve the same function as <code>alt</code> text. And since not all screen readers catch the <code>&lt;title&gt;</code> tag, you usually want to associate it with the <code>&lt;svg&gt;</code> tag using <code>aria-labelledby</code>. And if the SVG contains multiple images, text blocks, or interactivity, there’s even more to consider.</p>

<p><a href="https://www.w3.org/TR/graphics-aria-1.0/#role_definitions">I dug into the WAI-ARIA rabbit hole</a> and learned that maybe some of my SVGs could be <code>role="graphics-symbol"</code></p>

<blockquote>
<p>A graphical object used to convey a simple meaning or category, where the meaning is more important than the particular visual appearance.</p>
</blockquote>

<p>Axe missed all this, but Claude caught it. I wonder if there’s an automated scanner that could help me out.</p>
<h2 id="explain-your-asterisks">
  
    Explain your asterisks
  
</h2>

<p>If you’re going to denote require inputs using an asterisk <code>*</code> in the label, <a href="https://www.w3.org/WAI/WCAG22/Techniques/html/H90#description">you’d better provide a legend that explains it</a>. Even better, replace asterisk with <code>(required)</code>.</p>

<p>Oops, thanks for the reminder, Claude. I added an explainer to the form:</p>
<div class="highlight"><pre class="highlight plaintext"><code>&lt;small&gt;* asterisks denote required fields&lt;/small&gt;
</code></pre></div><h2 id="punctuation-as-labels">
  
    Punctuation as labels
  
</h2>

<p>I built a pagination component that looked like this:</p>
<div class="highlight"><pre class="highlight plaintext"><code>&lt; 1 … 45 46 47 … 104 &gt;
</code></pre></div>
<p>Claude reminded me that when a screen reader reads out those angle brackets and ellipses, it’s going to sound weird. I opened Voiceover and sure enough - it sounds weird.</p>

<p>I followed <a href="https://github.com/ddnexus/pagy">Pagy’s</a> example: the ellipses get <code>role="separator"</code> and the buttons get <code>aria-label="Next"</code>/<code>aria-label="Previous"</code>.</p>
<h2 id="table-header-cell-scopes">
  
    Table header cell scopes
  
</h2>

<p>A blind spot for me: I didn’t know about the <code>scope</code> attribute. <a href="https://www.w3.org/WAI/WCAG22/Techniques/html/H63">WCAG recommends</a> using <code>scope="col"</code> on table header <code>&lt;th&gt;</code> cells to associate them with their column. And also using <code>&lt;th scope="row"&gt;</code> for table body cells that identify the subject of the row.</p>

<p>Probably more useful for complex tables than simple ones. I’ll have to remember this.</p>

<hr>

<p>Thank goodness for automated scanners and the people who maintain them[^2]! The stuff I build is better for it. I was impressed by the bugs Claude caught, even though it surely wasn’t comparable to an accessibility audit by a real person.</p>

<p>[^1] My prompt: “You are an accessibility expert. Please review all the pages on this site and create a table of accessibility and WCAG violations”</p>

<p>[^2] By the way: thoughtbot maintains <a href="https://github.com/thoughtbot/capybara_accessibility_audit">CapybaraAccessibilityAudit</a> which uses Axe under the hood!</p>

<aside class="related-articles"><h2>If you enjoyed this post, you might also like:</h2>
<ul>
<li><a href="https://thoughtbot.com/blog/debugging-why-your-specs-have-slowed-down">Debugging Why Your Specs Have Slowed Down</a></li>
<li><a href="https://thoughtbot.com/blog/automating-barcode-scanner-tests-with-capybara">Automating barcode scanner tests with Capybara</a></li>
<li><a href="https://thoughtbot.com/blog/theme-based-iterations">Theme-Based Iterations</a></li>
</ul></aside>
<img src="https://feed.thoughtbot.com/link/24106/17349023.gif" height="1" width="1"/>]]></content>
    <summary>Automated scans taught me about some web stuff I forgot or never even know.</summary>
    <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
  </entry>
  <entry>
    <title>Why Duck Typer?</title>
    <link rel="alternate" href="https://feed.thoughtbot.com/link/24106/17348403/why-duck-typer"/>
    <author>
      <name>Thiago Araújo Silva</name>
    </author>
    <id>https://thoughtbot.com/blog/why-duck-typer</id>
    <published>2026-05-26T00:00:00+00:00</published>
    <updated>2026-05-21T18:49:29Z</updated>
    <content type="html"><![CDATA[<p><a href="https://github.com/thoughtbot/duck_typer">Duck Typer</a> is a Ruby gem that <a href="https://thoughtbot.com/blog/meet-duck-typer-your-new-duck-typing-friend">validates interface
compatibility</a> across polymorphic classes sharing the same
role, so they can be used interchangeably. It detects and clearly
reports interface drift directly in your test suite.</p>

<p>Since Duck Typer launched, there’s been some discussion about the
validity of interface testing. In this post, I want to make the case
for it.</p>
<h2 id="quotinterface-tests-are-fragile-so-you-shouldn39t-write-themquot">
  
    “Interface tests are fragile, so you shouldn’t write them”
  
</h2>

<p>That’s not true without context. How is your test suite structured?
What do you test? Obviously, if you write only
interface tests like this:</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="k">def</span> <span class="nf">test_interfaces_match</span>
  <span class="n">assert_interfaces_match</span> <span class="p">[</span><span class="no">StripeProcessor</span><span class="p">,</span> <span class="no">PaypalProcessor</span><span class="p">]</span>
<span class="k">end</span>
</code></pre></div>
<p>With no behavior tests to accompany it, that quote <em>will</em> be true.
Why? Because you’re not testing actual code behavior. Alone, Duck Typer
tests are fragile. So why should you still write them?</p>
<h2 id="quotbut-i-already-have-behavior-tests-that-catch-mismatchesquot">
  
    “But I already have behavior tests that catch mismatches”
  
</h2>

<p>You do, and they will catch mismatches eventually, assuming you have
good test coverage. The problem is <em>how</em> they catch them. A behavior
test will blow up with a <code>NoMethodError</code> or an <code>ArgumentError</code>,
but nothing about that tells you it’s an interface problem across
a group of classes. You have to figure that out yourself, then
work backwards to find which class drifted and what changed.</p>

<p>Duck Typer short-circuits that investigation. It tells you <em>what</em>
drifted and <em>where</em>, in a single message, before you ever hit a
behavioral failure:</p>
<div class="highlight"><pre class="highlight plaintext"><code>Expected StripeProcessor and PaypalProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: refund(transaction_id)
PaypalProcessor: refund not defined
</code></pre></div>
<p>There’s also a sharper version of this objection: “You can remove the
implementation and the test still passes, so it’s not a good test.”
That’s true, and it’s by design. Duck Typer checks shape, not behavior.
It explicitly marks that a set of classes is expected to evolve
together, and when one changes, the failure makes it clear. That’s a
different job than verifying correctness, and both are worth doing.</p>
<h2 id="it39s-about-quality-of-life">
  
    It’s about quality of life
  
</h2>

<p>At thoughtbot, we always valued testing UX and clear error reporting. <em>We
care about the details</em>. For example, this is a style of test generally
not encouraged here:</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">expect</span><span class="p">(</span><span class="n">objects</span><span class="p">).</span><span class="nf">to</span> <span class="n">eq</span><span class="p">([</span><span class="n">post_1</span><span class="p">,</span> <span class="n">post_2</span><span class="p">,</span> <span class="n">post_3</span><span class="p">])</span>
</code></pre></div>
<p>Assume that the <code>post</code> objects are complex Active Record instances. Can
you imagine what the error message will look like if one object has
differences? It will dump a huge blob of text that incurs overhead to
parse. What are we really testing there? That we’re getting the right
objects! Instead, we can use named identifiers to make error reporting
more actionable and crystal clear:</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">expect</span><span class="p">(</span><span class="n">objects</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:title</span><span class="p">)).</span><span class="nf">to</span> <span class="n">eq</span><span class="p">([</span><span class="s2">"Post 1"</span><span class="p">,</span> <span class="s2">"Post 2"</span><span class="p">,</span> <span class="s2">"Post 3"</span><span class="p">])</span>
</code></pre></div>
<p>Duck Typer applies the same principle to interface errors. Without
it, you only get generic Ruby errors that say nothing about
interface drift across classes. With Duck Typer, you also get a
clear, targeted failure:</p>
<div class="highlight"><pre class="highlight plaintext"><code>Expected StripeProcessor and BraintreeProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: charge(amount, currency:)
BraintreeProcessor: charge(amount, currency:, description:)

StripeProcessor: refund(transaction_id)
BraintreeProcessor: refund(transaction_id, amount)
</code></pre></div><h2 id="it-communicates-design-intent-as-actionable-errors">
  
    It communicates design intent as actionable errors
  
</h2>

<p>I wish Ruby had interfaces. As I said in the <a href="https://thoughtbot.com/blog/meet-duck-typer-your-new-duck-typing-friend">introductory
post</a>, I want to be alerted of interface drift because it’s
a great developer experience feature.</p>

<p>It’s not always obvious when classes are supposed to be used
interchangeably. A clear error message communicates which classes
share a role and what shape their interfaces should have.</p>

<p>What if you join a legacy project where the original developers left a
long time ago? Duck Typer would be super helpful there too.</p>

<p>A concrete example: Null Objects. You add a <code>deactivate</code> method to
<code>User</code>, and your behavior tests for <code>User</code> pass. But <code>NullUser</code>, which
is supposed to be interchangeable with <code>User</code>, silently drifts because
nobody remembered to update it. Behavior tests on <code>User</code> won’t catch
that. Duck Typer will, immediately, because it treats those classes as a
group that must stay in sync. It also reminds you to write
the actual behavior test for <code>NullUser#deactivate</code>.</p>

<p>As a developer who loves targeted feedback, that is right up my alley.</p>
<h2 id="it-helps-you-think-about-design">
  
    It helps you think about design
  
</h2>

<p>Let’s say that introducing a <code>do_stuff</code> public method in
<code>StripeProcessor</code> is the easiest way to accomplish a goal. You add it,
but get a test failure like the following:</p>
<div class="highlight"><pre class="highlight plaintext"><code>Expected StripeProcessor and PaypalProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: do_stuff(data)
PaypalProcessor: do_stuff not defined
</code></pre></div>
<p>That message doesn’t just report interface drift. It actually asks:</p>

<blockquote>
<p>Why are you doing that? A public method in StripeProcessor should
also exist in the other processors.</p>
</blockquote>

<p>Most likely, your <code>do_stuff</code> method is not in the right place. Maybe it
belongs in a collaborator object, or maybe it should be a private method
that isn’t part of the public interface at all.</p>

<p>The same applies to differing method parameters; if you introduce a
parameter in one class but it is not needed in another class from the
same interface, you are <em>probably</em> doing something wrong.</p>
<h2 id="quotbut-that39s-just-like-shoulda-matchersquot">
  
    “But that’s just like shoulda-matchers”
  
</h2>

<p>Not quite. <a href="https://github.com/thoughtbot/shoulda-matchers">Shoulda Matchers</a> are great for shortening
TDD feedback loops when working with Rails conventions. They verify a
single object’s declarations: does this model <code>have_many :posts</code>? Does
it <code>validate_presence_of :email</code>? That’s inward-facing: one object, one
declaration.</p>

<p>In fact, once the code has enough behavior coverage, you could delete
the shoulda-matchers tests entirely. They’ve done their job.</p>

<p>Duck Typer is cross-cutting. It checks whether a <em>group</em> of objects
agrees on a shared interface. The question isn’t “does <code>StripeProcessor</code>
have a <code>charge</code> method?” but “do <code>StripeProcessor</code>, <code>PaypalProcessor</code>,
and <code>BraintreeProcessor</code> all define <code>charge</code> with the same signature?”
That’s a fundamentally different concern, and one that single-object
matchers can’t express.</p>
<h2 id="quotbut-it-doesn39t-catch-errors-in-productionquot">
  
    “But it doesn’t catch errors in production”
  
</h2>

<p>Some prefer an approach where the interface is validated at class load
time: declare the contract, and if a class doesn’t conform, raise a
<code>RuntimeError</code> immediately. That way, mismatches surface as errors in
production rather than only in tests.</p>

<p>That’s a valid approach, although not exactly great. In a typed
language, an interface mismatch would never be deployed because the
code wouldn’t compile. Ruby doesn’t have a compiler, but it has
its own equivalent: the test suite. And guess what inhibits bad
deployments in Ruby projects? In all my years working with Ruby,
I’ve <em>never</em> seen a project without a CI pipeline. If tests fail,
your code doesn’t get deployed. In practice, the safety net is
the same.</p>

<p>On top of that, runtime checks add metaprogramming to your
production code, and you’d still need tests to verify the setup
is correct.</p>

<p>That’s why Duck Typer deliberately stays in the test suite: it’s
Ruby’s natural place to enforce constraints like this, and your
implementation stays clean, without workarounds that try to mimic
static typing at runtime.</p>

<p>If you want compile-time or runtime guarantees, tools like
<a href="https://sorbet.org">Sorbet</a> or <a href="https://github.com/ruby/rbs">RBS</a>
take a fundamentally different approach to the same problem and you
wouldn’t need Duck Typer. That said, Duck Typer gives you some of those
benefits with a fraction of the effort, at least when it comes to
interfaces.</p>
<h2 id="wrapping-up">
  
    Wrapping up
  
</h2>

<p>Duck Typer won’t replace your behavior tests, and it was never meant to.
It’s a small, focused tool that gives you targeted feedback when
interfaces drift. It’s usually a one-liner to add, has no runtime
dependencies, and lives only in your test environment. If you value
clear error messages and care about keeping polymorphic classes in
sync, <a href="https://github.com/thoughtbot/duck_typer">give it a try</a>.</p>

<aside class="related-articles"><h2>If you enjoyed this post, you might also like:</h2>
<ul>
<li><a href="https://thoughtbot.com/blog/waiting-for-a-factory-bot">Waiting For a Factory~~Girl~~Bot</a></li>
<li><a href="https://thoughtbot.com/blog/don-t-inline-rescue-in-ruby">Don’t Inline-Rescue in Ruby</a></li>
<li><a href="https://thoughtbot.com/blog/your-flaky-tests-might-be-time-dependent">Your flaky tests might be time dependent</a></li>
</ul></aside>
<img src="https://feed.thoughtbot.com/link/24106/17348403.gif" height="1" width="1"/>]]></content>
    <summary>Some say interface tests are fragile and shouldn't be written. I disagree. Here's why I think they're worth writing.
</summary>
    <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
  </entry>
  <entry>
    <title>Biometrics authentication for your mobile app</title>
    <link rel="alternate" href="https://feed.thoughtbot.com/link/24106/17347610/biometrics-authentication-for-your-mobile-app"/>
    <author>
      <name>Rakesh Arunachalam</name>
    </author>
    <id>https://thoughtbot.com/blog/biometrics-authentication-for-your-mobile-app</id>
    <published>2026-05-25T00:00:00+00:00</published>
    <updated>2026-05-22T08:39:23Z</updated>
    <content type="html"><![CDATA[<p>Biometrics (Face ID, Fingerprint and more) authentication have quickly become
something users expect from a modern mobile app. These are fast, familiar, and
removes friction that comes with typing passwords on the phone. With React
Native, biometrics authentication can be an easy feature or a tricky one
depending on the options you take. There are multiple ways to build it, and they
do not all offer the same level of security.</p>

<p>In this blog post, I’m going to share three approaches that we evaluated for a
recent project. All three approaches can enable a smooth authentication
experience, but they come with different tradeoffs in terms of implementation
complexity, user experience, and security implications. If you are building this
feature for a banking/financial services app, a healthcare app, or a product
handling sensitive user data, these tradeoffs matter a lot. In this post, for
each approach of biometrics authentication, we’ll explore how they work, where
they fall short, and when each makes sense.</p>
<h2 id="simple-prompt-approach-1">
  
    Simple Prompt (Approach 1)
  
</h2>

<p>The simplest way to add biometrics to your app is to use it as a UI level gate:</p>

<ul>
<li>Prompt the user to authenticate with their biometrics (face or fingerprint).</li>
<li>On success, read the auth token from secure storage</li>
</ul>

<p>This is what
<a href="https://github.com/sbaiahmed1/react-native-biometrics#simpleprompt">simplePrompt</a>
in <code>react-native-biometrics</code> does. It triggers the native biometric dialog and
returns a <code>boolean</code> indicating <strong>success</strong> or <strong>failure</strong>. The critical thing to
understand about this approach is that the biometric check and the token
retrieval are two separate, unlinked operations. The app calls <code>simplePrompt</code>,
gets back <code>true</code>, then independently reads the token from storage. There is no
cryptographic connection between them and the biometric result is merely a
signal to our code, not a hardware enforced gate on the data itself.</p>
<div class="highlight"><pre class="highlight javascript"><code><span class="c1">// Pseudocode showing how this works</span>
<span class="kd">const</span> <span class="nx">success</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">biometrics</span><span class="p">.</span><span class="nf">prompt</span><span class="p">(</span><span class="dl">'</span><span class="s1">Confirm your identity</span><span class="dl">'</span><span class="p">)</span>

<span class="k">if </span><span class="p">(</span><span class="nx">success</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">secureStorage</span><span class="p">.</span><span class="nf">read</span><span class="p">(</span><span class="dl">'</span><span class="s1">auth_token</span><span class="dl">'</span><span class="p">)</span>
  <span class="c1">// log user in</span>
<span class="p">}</span>
</code></pre></div><h3 id="expo-vs-react-native-cli-approach-1">
  
    Expo vs React Native CLI (Approach 1)
  
</h3>

<p>With <a href="https://expo.dev/">Expo</a> managed workflow, you’d reach for
<a href="https://docs.expo.dev/versions/latest/sdk/local-authentication/">expo-local-authentication</a>
rather than <code>react-native-biometrics</code>. The API is nearly identical, returns a
result object with a <code>success</code> boolean, and it works without ejecting or adding
any native configuration. To store and retrieve the auth token securely, pair it
with
<a href="https://docs.expo.dev/versions/latest/sdk/securestore/">expo-secure-store</a>,
which writes to the iOS Keychain and Android Keystore under the hood.</p>

<p>With <a href="https://reactnative.dev/docs/getting-started-without-a-framework">React Native
CLI</a> (bare
workflow),
<a href="https://github.com/sbaiahmed1/react-native-biometrics">react-native-biometrics</a>
is the natural choice for the biometric prompt. For token storage, the
equivalent is
<a href="https://github.com/oblador/react-native-keychain">react-native-keychain</a>, which
gives you direct access to the iOS Keychain and Android Keystore and supports
more granular security configuration than <code>expo-secure-store</code>.</p>
<h3 id="pros-approach-1">
  
    Pros (Approach 1)
  
</h3>

<ul>
<li>Easy to implement with minimal boilerplate and works out of the box on both
platforms</li>
<li>Supports Face ID, Touch ID, and fingerprint without extra configuration</li>
<li>Works across iOS and Android with a consistent API</li>
<li>No backend required</li>
<li>Multi device support as each device independently manages its own stored token</li>
</ul>
<h3 id="cons-approach-1">
  
    Cons (Approach 1)
  
</h3>

<ul>
<li>Biometric enforcement happens at the App level, not the OS or hardware level.
The biometric check and the token read are two independent steps with no
cryptographic coupling between them</li>
<li>On a jailbroken or rooted device, tools like <a href="https://frida.re/">Frida</a> can be
used to hook into the app at runtime, force <code>simplePrompt</code> to return <code>true</code>,
and retrieve the token without the user actually authenticating</li>
<li>Does not meet
<a href="https://www.microsoft.com/en-us/security/business/security-101/what-is-fido2">FIDO2</a>
or WebAuthn standards as there is no challenge-response, no server side
verification, and no cryptographic proof that a real biometric event occurred
on the device</li>
<li>The stored token is not hardware locked. Code that bypasses the biometric gate
can read it directly from storage</li>
<li>Not appropriate for apps handling sensitive financial, healthcare, or
regulated data</li>
</ul>
<h2 id="cryptographic-key-pair-approach-2">
  
    Cryptographic Key Pair (Approach 2)
  
</h2>

<p>This approach upgrades biometrics from a UI gate to something the server can
independently verify. The
<a href="https://github.com/sbaiahmed1/react-native-biometrics#-key-management"><code>createKeys()</code></a>
method in <code>react-native-biometrics</code> generates an asymmetric key pair. The device
holds a private key locked behind biometrics and the server holds the matching
public key. Login is just the device signing a challenge and the server
verifying the signature.</p>

<p>What makes each piece do its job:</p>

<ul>
<li>
<strong>Private key</strong> — Created inside the device chip at enrollment and never
exported. It is only unlocked by a passing biometric scan.</li>
<li>
<strong>Public key</strong> — Returned to the app and registered on the server. It is used
only to verify signatures.</li>
<li>
<strong>Challenge</strong> — A one-time UUID issued by the server at login. The server
enforces an <code>expires_at</code> field so late or replayed submissions are rejected.</li>
<li>
<strong>Signature</strong> — The chip’s signed challenge sent to the server. The server
checks it against the stored public key and issues an access token.</li>
</ul>

<p>The server never learned your biometric, your private key, or even your
password. It just confirmed the right chip signed the right challenge at the
right time.</p>
<h3 id="enrollment">
  
    Enrollment
  
</h3>

<p>The device generates a key pair inside the hardware chip. The private key never
leaves. The public key gets sent to the server.</p>

<p><em>Private key example:</em> Stays on device, locked by biometrics, never transmitted.</p>
<div class="highlight"><pre class="highlight plaintext"><code>-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA2a2rwplBQLF29amygykEMmYz0+Ik9e8bKGFCNSGBaGMq
mMV5FBtLGMGMmFnMjQKFMkBHiNIEuAsYhVMqAC+XAAA...
-----END RSA PRIVATE KEY-----
</code></pre></div>
<p><em>Public key example:</em> Sent to server at enrollment, stored against the user’s
account.</p>
<div class="highlight"><pre class="highlight plaintext"><code>-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLzamygyk
EMmYz0+Ik9e8bKGFCNSGBaGMqmMV5FBtLGJmFnMjQKF...
-----END PUBLIC KEY-----
</code></pre></div><h3 id="authentication">
  
    Authentication
  
</h3>

<p><strong>Step 1:</strong> User on the device says: “I want to log in.”</p>

<p><strong>Step 2:</strong> Server generates a fresh challenge and sends it back:</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"challenge"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a3f8c2d1-9b4e-4f7a-8c3d-2e1f0a9b8c7d"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"expires_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-05-01T10:00:30Z"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div>
<p>Just a random UUID. Expires in 30 seconds, useless after that.</p>

<p><strong>Step 3:</strong> Device prompts Face ID / fingerprint.</p>

<p>Biometric passes → chip unlocks private key → signs the challenge:</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"challenge"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a3f8c2d1-9b4e-4f7a-8c3d-2e1f0a9b8c7d"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"signature"</span><span class="p">:</span><span class="w"> </span><span class="s2">"XrAGWHSPpnmqzQxL3kVt9Yw2NcBdEfUoRiMaTlKsPqJhGvCxZnWbOuIyDe..."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div>
<p>This is sent over the network. No password or token or private key. Just the
challenge and its signature.</p>

<p><strong>Step 4:</strong> Server verifies:</p>
<div class="highlight"><pre class="highlight plaintext"><code>verify(
  publicKey  = &lt;the one stored at enrollment&gt;,
  challenge  = "a3f8c2d1-9b4e-4f7a-8c3d-2e1f0a9b8c7d",
  signature  = "XrAGWHSP..."
) → ✅ true
</code></pre></div>
<p>Server responds:</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"access_token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"expires_in"</span><span class="p">:</span><span class="w"> </span><span class="mi">720</span><span class="w"> </span><span class="err">//</span><span class="w"> </span><span class="mi">12</span><span class="w"> </span><span class="err">hours</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div><h3 id="expo-vs-react-native-cli-approach-2">
  
    Expo vs React Native CLI (Approach 2)
  
</h3>

<p><code>expo-local-authentication</code> implements the simple prompt pattern from Approach 1
and exposes no key management APIs. There is no <code>createKeys()</code> or
<code>createSignature()</code> equivalent in the Expo SDK, so you cannot implement the
cryptographic flow with the standard managed workflow or Expo Go.</p>

<p>With React Native CLI, <code>react-native-biometrics</code> links natively on install and
gives you direct access to <code>createKeys()</code>, <code>createSignature()</code>, and
<code>deleteKeys()</code>. Your server needs three endpoints: one to store the public key
at enrollment, one to issue a challenge, and one to verify the signature and
return an access token.</p>
<h3 id="pros-approach-2">
  
    Pros (Approach 2)
  
</h3>

<ul>
<li>The biometric check and authentication are cryptographically coupled: a valid
signature can only be produced if the biometric passes, enforced by the
hardware chip, not App code.</li>
<li>Protects against replay attacks: every challenge is a one-time UUID with a
short expiry window.</li>
<li>Closest to
<a href="https://www.microsoft.com/en-us/security/business/security-101/what-is-fido2">FIDO2</a>
of the three approaches with hardware backed keys, biometric gated signing,
and server-side challenge-response compared to the other approaches.</li>
<li>Resistant to runtime hooking tools like <a href="https://frida.re/">Frida</a> as any
spoofed signature here fails server-side verification because producing a
valid signature requires the private key, which never leaves the hardware
chip.</li>
</ul>
<h3 id="cons-approach-2">
  
    Cons (Approach 2)
  
</h3>

<ul>
<li>More complex to implement than Approach 1 as it requires backend work for key
enrollment, challenge-response, and server-side verification.</li>
<li>Multi device support is slightly complex as it requires the server to manage,
store, and revoke a separate public key for every enrolled device per user.</li>
<li>
<code>expo-local-authentication</code> does not support this flow; Expo users need a bare
React Native workflow, which adds build infrastructure overhead compared to
the other two approaches.</li>
</ul>
<h2 id="keychain-approach-3">
  
    Keychain (Approach 3)
  
</h2>

<p>This approach sits between the previous two. Like Approach 1, the auth token
lives on the device. Like Approach 2, the biometric check is enforced by the OS
at the hardware level, not by the React Native JS code.</p>

<p>When the token is written to the <a href="https://developer.apple.com/documentation/security/keychain_services">iOS
Keychain</a>
or <a href="https://developer.android.com/privacy-and-security/keystore">Android
Keystore</a> with a
biometric access control flag, the OS will refuse to decrypt and return the
value unless a successful biometric scan happens. The biometric prompt and the
token decryption become a single OS-level operation. The App does not call a
separate prompt API and then read the value, it just reads the value, and the OS
shows the biometric prompt as part of completing that read.</p>
<div class="highlight"><pre class="highlight javascript"><code><span class="c1">// Pseudocode showing how this works</span>
<span class="kd">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">secureStorage</span><span class="p">.</span><span class="nf">read</span><span class="p">(</span><span class="dl">'</span><span class="s1">auth_token</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">requireAuthentication</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">})</span>
<span class="c1">// log user in</span>
</code></pre></div>
<p>In practice, most apps store only a refresh token behind this gate and keep the
access token in memory. The refresh token is long lived but never leaves the
device, while the access token is short lived (~12 hours), lives in memory only,
and is obtained by exchanging the refresh token at the start of each session. If
an access token leaks, it expires quickly on its own. The refresh token, the
more powerful credential, is hardware protected and never transmitted after the
initial login.</p>
<h3 id="expo-vs-react-native-cli-approach-3">
  
    Expo vs React Native CLI (Approach 3)
  
</h3>

<p>With Expo,
<a href="https://docs.expo.dev/versions/latest/sdk/securestore/">expo-secure-store</a>
implements this pattern via <code>requireAuthentication: true</code>. The biometric prompt
fires automatically on read, and <code>SecureStore.canUseBiometricAuthentication()</code>
lets you check up front whether the device has biometrics enrolled before opting
the user into biometric sign in. No native modules to install, and it works
inside the managed workflow.</p>

<p>With React Native CLI,
<a href="https://github.com/oblador/react-native-keychain">react-native-keychain</a> gives
the same OS enforced gate but with more granular controls. You pass
<code>accessControl: ACCESS_CONTROL.BIOMETRY_CURRENT_SET</code> and <code>securityLevel:
SECURITY_LEVEL.SECURE_HARDWARE</code> to <code>setGenericPassword</code>, which guarantees the
key is stored securely.</p>
<h3 id="pros-approach-3">
  
    Pros (Approach 3)
  
</h3>

<ul>
<li>Biometric enforcement happens at the OS/hardware level, not the App level. A
patched App using tools like Frida that bypasses the biometric call still
cannot read the token because the OS will not release the encryption key.</li>
<li>No backend work required, unlike Approach 2. Only the token storage layer
changes, the rest of the auth stack stays the same.</li>
<li>Works inside the Expo managed workflow via <code>expo-secure-store</code>, so we don’t
need a Bare workflow for this to be implemented.</li>
<li>Multi device support is easy. Each device stores its own copy of the token
encrypted securely.</li>
<li>
<code>BIOMETRY_CURRENT_SET</code> invalidates the stored token automatically when a new
biometric is enrolled, which is a sensible default for apps handling sensitive
data.</li>
</ul>
<h3 id="cons-approach-3">
  
    Cons (Approach 3)
  
</h3>

<ul>
<li>The token still lives on the device. If the access token is compromised
through a network breach or server side leak, biometrics on the device cannot
help. This can be mitigated by using a refresh token.</li>
<li>Token expiry and rotation has to be handled in the App, typically by pairing
this with a refresh token pattern explained above.</li>
<li>There is no server side proof that biometrics were used. The server sees a
normal token request and trusts the client. Approach 2 is the only one of the
three that gives the server, cryptographic evidence of a real biometric event.</li>
<li>Does not meet
<a href="https://www.microsoft.com/en-us/security/business/security-101/what-is-fido2">FIDO2</a>
or WebAuthn standards as there is no challenge-response and no asymmetric key
pair, just hardware backed symmetric storage.</li>
</ul>
<h2 id="conclusion">
  
    Conclusion
  
</h2>

<table>
<thead>
<tr>
<th></th>
<th>Approach 1</th>
<th>Approach 2</th>
<th>Approach 3</th>
</tr>
</thead>
<tbody>
<tr>
<td>Biometric enforcement</td>
<td>App (JS)</td>
<td>Hardware chip</td>
<td>OS / hardware</td>
</tr>
<tr>
<td>Frida / patched app bypass</td>
<td>Vulnerable</td>
<td>Resistant</td>
<td>Resistant</td>
</tr>
<tr>
<td>Backend required</td>
<td>No</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Server-side biometric proof</td>
<td>No</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Replay attack protection</td>
<td>No</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Multi-device support</td>
<td>Easy</td>
<td>Complex</td>
<td>Easy</td>
</tr>
<tr>
<td>Expo managed workflow</td>
<td>Yes</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>FIDO2 / WebAuthn alignment</td>
<td>No</td>
<td>Closest</td>
<td>No</td>
</tr>
</tbody>
</table>

<p>If your app is relatively simple (a productivity tool, social app or utility),
and biometrics is primarily a convenience feature for a fast login, Approach 1
is a reasonable choice. The implementation is straightforward and the tradeoffs
are acceptable when there is no sensitive financial or personal data at stake.</p>

<p>If you are building a banking app, a financial services product, a healthcare
app, or anything that handles regulated personal data, the bar is higher.
Approach 1 is not appropriate here. Between Approaches 2 and 3, the deciding
factor is usually team capability and backend access. Approach 3 requires no
backend changes, making it the practical default for most teams. Approach 2 is
the strongest option but requires a backend. If your team has the backend access
and is comfortable with the added complexity, Approach 2 is the right call for
maximum security for the mobile app.</p>

<p>In practice, most apps in sensitive industries start with Approach 3 and move
toward Approach 2 if compliance requirements or a security audit demands server
side verification.</p>

<aside class="related-articles"><h2>If you enjoyed this post, you might also like:</h2>
<ul>
<li><a href="https://thoughtbot.com/blog/designing-for-ios-taming-uibutton">Designing for iOS: Taming UIButton</a></li>
<li><a href="https://thoughtbot.com/blog/designing-for-ios-graphics-performance">Designing for iOS: Graphics &amp;amp; Performance</a></li>
<li><a href="https://thoughtbot.com/blog/ios-code-review-guidlines">iOS Code Review: Loose Guidelines</a></li>
</ul></aside>
<img src="https://feed.thoughtbot.com/link/24106/17347610.gif" height="1" width="1"/>]]></content>
    <summary>Practical approaches to add biometrics authentication to a mobile app.
</summary>
    <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
  </entry>
  <entry>
    <title>Claude Code + Figma for non-technical designers</title>
    <link rel="alternate" href="https://feed.thoughtbot.com/link/24106/17346045/claude-code-figma-for-non-technical-designers"/>
    <author>
      <name>Ferdia Kenny</name>
    </author>
    <id>https://thoughtbot.com/blog/claude-code-figma-for-non-technical-designers</id>
    <published>2026-05-22T00:00:00+00:00</published>
    <updated>2026-05-21T11:00:42Z</updated>
    <content type="html"><![CDATA[<p>Moving from Figma to Claude Code and back again is now possible. This could be a game changer for less technical, more visual Product Designers.</p>
<h2 id="what-is-this-post-about">
  
    What is this post about?:
  
</h2>

<p>In June 2025, <a href="https://www.figma.com/blog/introducing-figma-mcp-server/">Figma announced the beta release of their MCP server</a>, a connection that lets AI coding tools read your Figma files directly. Claude Code, Anthropic’s agentic coding tool, was one of the first supported clients. Instead of describing a design to an AI in words, you give it a link to a Figma frame. It reads the components, the spacing, the design tokens, the structure, and builds the HTML and CSS from that.</p>

<p>That was interesting. But having tried it out previously, I found I’d often want to jump back into Figma to make changes to the Claude Code output. While it was possible to do this directly in the code, it wasn’t my strong suit and I found designing in Figma to be a lot faster.</p>

<p>Now Figma have gone one step further. You can now take a live, running interface built in Claude Code and send it back to Figma as editable layers. The workflow is no longer one-way. Design becomes code which becomes design again.</p>
<h2 id="who-this-workflow-might-suit">
  
    Who this workflow might suit:
  
</h2>

<p><a href="https://thoughtbot.com/blog/what-sets-thoughtbot-designers-apart">Designers in thoughtbot are pretty unique</a> in that for years, they have been writing and building prototypes in code. But for many designers who do not have a technical background, perfecting CSS, running commands in the terminal, opening PRs; these might all have seemed daunting.</p>

<p>If you’re an experienced technical designer, you probably won’t need this workflow. If you’re a designer who likes to design by prompt, directly in Claude Code, you also probably won’t need this workflow.</p>

<p>But if, like me, you are quite a visual designer who is not especially technical, these new advancements open up some exciting possibilities for shifting your designs to code and back, allowing you to make precise changes in a medium that you’re more comfortable with (Figma) and still ship some usable code.</p>
<h2 id="why-i-like-it">
  
    Why I like it:
  
</h2>

<p>I like this workflow because I am not especially technical as a designer. I always found the terminal quite intimidating. This allows me to create prototypes in code.</p>

<p>However, I also like starting out in Figma because I don’t want Claude to do my thinking for me.</p>

<p>For example, let’s say I prompt Claude to create a website where users can search for properties in their area. Claude might create features that come as standard on many property sites, like searching for properties in a map view. Each pan/drag that changes the viewport means a new API call to fetch properties in that bounding box. But in my specific case, I may not want the expense of that API, especially at MVP stage. But if I prompt on autopilot, there is a risk that kind of feature would sneak in there without me actually spending the time considering it in detail.</p>

<p>So personally, I find that prompting from the get-go can introduce scope creep and lead to bloated projects.</p>

<p>I like to do the thinking behind the product myself (the really hard part of Product Design). This involves prioritisation, <a href="https://thoughtbot.com/blog/building-your-product-from-zero-to-mvp">cutting scope</a>, assessing technical feasibility and asking “<a href="https://thoughtbot.com/playbook/customer-discovery/strategic-planning">should we build this?</a>”. After this, I can use Figma and Claude Code as tools to help me with the execution (which is now a less hard part than previously).</p>
<h2 id="what-you-need">
  
    What you need:
  
</h2>

<ul>
<li>A paid Figma account</li>
<li>A paid Claude Code / Anthropic account.</li>
<li><a href="https://www.codecademy.com/article/what-is-nodejs">Node.js</a></li>
</ul>
<h2 id="how-to-set-it-up">
  
    How to set it up:
  
</h2>
<h3 id="step-1---install-claude-code">
  
    Step 1 - Install Claude Code
  
</h3>

<p>I promise this is not as scary as it sounds.</p>

<p>You open the Terminal on your Mac (Applications &gt; Utilities &gt; Terminal). Paste in the following command and hit enter:</p>
<div class="highlight"><pre class="highlight plaintext"><code>npm install -g @anthropic-ai/claude-code
</code></pre></div>
<p><img src="https://images.thoughtbot.com/g5te83k98wojbb4p2c0rn6hqpw4o_2%20Install%20Claude%20Code.png" alt="Screenshot of a white computer terminal with minimum writing and the install prompt highlighted in black"></p>

<p>You’ll see some text scrolling; that’s normal. When it finishes and gives you a <code>$</code> prompt back, try typing:</p>
<div class="highlight"><pre class="highlight plaintext"><code>claude
</code></pre></div>
<p>Hit enter and you should be in.</p>

<p><img src="https://images.thoughtbot.com/u5unq407xzopooeymtp0acjisb1a_3%20Claude%20Code%20running.png" alt="Claude Code running in a white computer terminal"></p>
<h3 id="step-2---project-storage">
  
    Step 2 - Project Storage
  
</h3>

<p>Next, you need to create a folder for your project and then open Claude Code inside it.</p>

<p>Submit this into your terminal and hit enter:</p>
<div class="highlight"><pre class="highlight plaintext"><code>mkdir ~/Desktop/my-projectcd ~/Desktop/my-projectclaude
</code></pre></div>
<p>Follow the first-time setup to log in. When you see a <code>&gt;</code> prompt, you’re in!</p>
<h3 id="step-3---connect-figma">
  
    Step 3 - Connect Figma
  
</h3>

<p>At the &gt; prompt, paste in and hit enter on:</p>
<div class="highlight"><pre class="highlight plaintext"><code>claude mcp add --transport http figma https://mcp.figma.com/mcp
</code></pre></div>
<p>Type <code>claude</code> to reopen, then <code>/mcp</code>, select Figma, and press Enter. It will open your browser to authorise the connection.</p>
<h3 id="set-your-standards">
  
    Set your standards
  
</h3>

<p>Before converting anything, create a CLAUDE.md file in your project folder. This is a plain text brief that Claude Code reads at the start of every session. It should contain what kind of code you want, naming conventions, how hover states should work, what a developer will need to wire up later and so on. If your company has specific development standards, this is the place to add them.</p>

<p>Getting this right once means every screen you build will follow the same rules.</p>
<h3 id="start-building">
  
    Start building
  
</h3>

<p>Once you have designed a frame in Figma you can hit “Command + L” on Mac to copy a link to the frame.</p>

<p>Then you simply paste it directly into the next line of the terminal that is running Claude Code. You can add some additional instructions here too such as:</p>
<div class="highlight"><pre class="highlight plaintext"><code>Build this design as clean, semantic HTML and CSS following the instructions in CLAUDE.md. Here's the frame: [your link]
</code></pre></div>
<p><img src="https://images.thoughtbot.com/euellfoo5qivbn958effilsrij6f_5%20Example%20Command%20in%20the%20Terminal%20Blurred.png.png" alt="A screenshot of a white computer terminal that is running Claude Code. It has a welcome message and a prompt which includes a link to a Figma design"></p>

<p>It’s also a good idea to ask for a preview with a command like:</p>
<div class="highlight"><pre class="highlight plaintext"><code>Start a local server so I can preview this in my browser
</code></pre></div>
<p>You can then see what Claude cooks up in your browser. And when you are ready to send it back to Figma, keep the browser preview open and go back to Terminal. At the Claude Code &gt; prompt, type:</p>
<div class="highlight"><pre class="highlight plaintext"><code>Send this to Figma
</code></pre></div>
<p>Claude Code is watching the browser preview it started, so you can tell it there what you want to capture. It will create a new frame in your Figma file with editable layers. From there, you can correct the issues you see, and then start the process again by sending your redesign back to Claude Code.</p>

<p>The GIF below shows a wonky mobile navigation Claude created being sent to Figma, and then the adjustments made to this directly in Figma iteself.</p>

<p><img src="https://images.thoughtbot.com/4vwjhtfjoj2e4r6q0bsvo06ia5xj_Sending%20to%20Figma%20Optimised%20GIF.gif" alt="A GIF showing a mobile navigation bar with very messy spacing. Above the shot there is a toolbar that is clicked to send the navigation to Figma"><img src="https://images.thoughtbot.com/con5ym5ao2kchqsr7o9rwat5qz7t_6.%20Working%20on%20it%20in%20Figma.png" alt="An image of the above mobile navigation, now being worked on in Figma directly"></p>

<p><img src="https://images.thoughtbot.com/ho661hi38pwhr41l80qkfzn4cd15_7.%20Sub%20Navigation%20redesigned.png" alt="The same mobile navigation bar adjusted in Figma to correct the spacing issues"></p>

<hr>
<h1 id="conclusion">
  
    Conclusion:
  
</h1>

<p>And that’s it, that is the loop. It takes a session or two to feel natural. But after that, it becomes a fun new way of working.</p>

<p>In the next blog we will share how to correctly map your design tokens and components so that Claude uses them correctly when sending files over and back to take this workflow to the next level.</p>

<aside class="related-articles"><h2>If you enjoyed this post, you might also like:</h2>
<ul>
<li><a href="https://thoughtbot.com/blog/how-to-use-chatgpt-to-find-custom-software-consultants">How to Use ChatGPT to Find Custom Software Consultants</a></li>
<li><a href="https://thoughtbot.com/blog/from-idea-to-impact-the-role-of-rapid-prototyping-in-agetech">From idea to impact: The role of rapid prototyping in AgeTech</a></li>
<li><a href="https://thoughtbot.com/blog/theme-based-iterations">Theme-Based Iterations</a></li>
</ul></aside>
<img src="https://feed.thoughtbot.com/link/24106/17346045.gif" height="1" width="1"/>]]></content>
    <summary>Moving from Figma to Claude Code and back again is now possible. This could be a game changer for less technical, more visual Product Designers.</summary>
    <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
  </entry>
</feed>
