<?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: Zihang Dong 董子航</title>
    <description>The latest articles on DEV Community by Zihang Dong 董子航 (@dngzihng114379).</description>
    <link>https://dev.to/dngzihng114379</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3855987%2Fdd1ab560-0150-40f1-accb-88eadf95c919.png</url>
      <title>DEV Community: Zihang Dong 董子航</title>
      <link>https://dev.to/dngzihng114379</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dngzihng114379"/>
    <language>en</language>
    <item>
      <title>I Built a Browser-Based Typing Speed Test That Handles 15s to 5-Minute Tests (and How It Works)</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sat, 20 Jun 2026 10:08:18 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-built-a-browser-based-typing-speed-test-that-handles-15s-to-5-minute-tests-and-how-it-works-12mf</link>
      <guid>https://dev.to/dngzihng114379/i-built-a-browser-based-typing-speed-test-that-handles-15s-to-5-minute-tests-and-how-it-works-12mf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A zero-dependency, vanilla-JS approach to measuring WPM, accuracy, and keystrokes — plus the UX lessons I learned while building a typing test that supports short bursts and long endurance runs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmf8u427ahyk5etocxdg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmf8u427ahyk5etocxdg.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
A few weeks ago I noticed that most online typing tests either force you into a 60-second sprint or feel too bare-bones. I wanted something that let users pick their own test length (15s, 30s, 60s, 3m, or 5m) while still running &lt;strong&gt;100% in the browser&lt;/strong&gt; with no upload, no signup, and no tracking beyond standard analytics.&lt;/p&gt;

&lt;p&gt;That became the &lt;a href="https://toolknit.com/tools/typing-test.html?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=typing-test"&gt;ToolKnit Typing Speed Test&lt;/a&gt;. Here's how I built it, the engineering choices I made, and the UI fixes I had to iterate on before it felt right.&lt;/p&gt;


&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;A typing test that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generates a random word list from a curated bank of common English words.&lt;/li&gt;
&lt;li&gt;Renders each character as a separate &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; so we can style correct / incorrect / current states.&lt;/li&gt;
&lt;li&gt;Listens to real keyboard events, starts the timer on the first character, and ignores modifier keys.&lt;/li&gt;
&lt;li&gt;Calculates live WPM, accuracy, and character count.&lt;/li&gt;
&lt;li&gt;Supports 5 durations (15s, 30s, 60s, 3m, 5m) with different word counts.&lt;/li&gt;
&lt;li&gt;Horizontally scrolls the prompt for long tests so the current character always stays visible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Try it first if you want to follow along: &lt;a href="https://toolknit.com/tools/typing-test.html?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=typing-test-demo"&gt;ToolKnit Typing Speed Test&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The core idea: render text as individual characters
&lt;/h2&gt;

&lt;p&gt;The trick to making the prompt feel responsive is splitting every character into its own element.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;textChars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;textChars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;char current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;char&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;textChars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;nbsp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;textChars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;span class="&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cls&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;" data-idx="&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;typingArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;span&lt;/code&gt; starts with a dim color. When the user types it, we add &lt;code&gt;correct&lt;/code&gt; or &lt;code&gt;incorrect&lt;/code&gt;. The upcoming character gets &lt;code&gt;current&lt;/code&gt; and a blinking caret border.&lt;/p&gt;

&lt;p&gt;This gives us three things for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual feedback&lt;/strong&gt; — the user always sees exactly what is correct, wrong, and upcoming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precise metrics&lt;/strong&gt; — because we know the index of every character, we can count correct and incorrect characters individually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-scroll&lt;/strong&gt; — we can scroll the typing area to the exact position of the current span.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Handling keyboard input correctly
&lt;/h2&gt;

&lt;p&gt;The UI listens to a real &lt;code&gt;keydown&lt;/code&gt; event on a hidden input that is focused when the user clicks the typing area. That avoids the quirks of trying to capture keystrokes directly on a &lt;code&gt;div&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;hiddenInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleInput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;handleInput&lt;/code&gt;, we ignore modifier keys, then decide whether the user is typing or deleting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Backspace&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;charIndex&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;prevSpan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;charIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;correct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;correctChars&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;incorrect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;incorrectChars&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;prevSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;correct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;incorrect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;prevSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;charIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;charIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;scrollToCurrent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backspace is surprisingly important for UX. Users expect to correct mistakes, and supporting it cleanly also lets us keep the accuracy metric honest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Calculating WPM and accuracy
&lt;/h2&gt;

&lt;p&gt;The standard WPM formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WPM = (correct characters / 5) / elapsed minutes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We divide by 5 because "one word" is traditionally defined as 5 characters. Accuracy is simply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accuracy = correct / (correct + incorrect) * 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateLiveStats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;wpm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;correctChars&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;correctChars&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;incorrectChars&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;correctChars&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live-wpm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wpm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live-acc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live-chars&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run the timer at a 200ms interval so the live stats update smoothly without hammering the DOM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scaling the prompt length for 3m and 5m tests
&lt;/h2&gt;

&lt;p&gt;The hardest UX problem was not the WPM math — it was the prompt length. A 5-minute test needs roughly 800 words to stay ahead of a fast typist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
              &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;
              &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
              &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
              &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That creates a single line of text far wider than the screen. The first fix was to allow horizontal scrolling. The second fix — the one that actually solved the problem — was to keep the &lt;strong&gt;current character&lt;/strong&gt; anchored at a fixed position (20% from the left) rather than letting it drift to the right edge as the user types.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scrollToCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;curSpan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;charIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;curSpan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;curSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetLeft&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;typingArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;typingArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollLeft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also added 40% right padding so the final characters never get stuck flush against the right edge.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#typing-area&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;overflow-x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;overflow-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding-right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the difference between a test that "works" and a test that feels good to use.&lt;/p&gt;




&lt;h2&gt;
  
  
  Accessibility and mobile gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use a hidden input for keyboard capture instead of &lt;code&gt;contenteditable&lt;/code&gt; to avoid the virtual keyboard pulling focus weirdly on mobile.&lt;/li&gt;
&lt;li&gt;Hide the scrollbar so the long prompt still looks clean, but keep the scroll behavior intact.&lt;/li&gt;
&lt;li&gt;The current character gets a blinking border-left caret, so users always know where they are even when the text is dim.&lt;/li&gt;
&lt;li&gt;Ignore &lt;code&gt;Shift&lt;/code&gt;, &lt;code&gt;Control&lt;/code&gt;, &lt;code&gt;Alt&lt;/code&gt;, &lt;code&gt;Tab&lt;/code&gt;, etc., so the timer does not start on a modifier-only keypress.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why this is privacy-first
&lt;/h2&gt;

&lt;p&gt;Everything happens in the browser. There is no backend that stores your text, your keystrokes, or your results. The word list is baked into the JS, the random generation is &lt;code&gt;Math.random()&lt;/code&gt;, and the stats are calculated live in memory. If you refresh, the test resets and nothing is sent anywhere.&lt;/p&gt;

&lt;p&gt;You can read more about the privacy model in the &lt;a href="https://toolknit.com/tools/typing-test.html?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=privacy-first"&gt;ToolKnit Typing Speed Test&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full source and live demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live tool: &lt;a href="https://toolknit.com/tools/typing-test.html?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=source-code"&gt;https://toolknit.com/tools/typing-test.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tool page: &lt;a href="https://toolknit.com/tools/typing-test.html?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=footer"&gt;https://toolknit.com/tools/typing-test.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Related reading: &lt;a href="https://toolknit.com/blog/how-many-paragraphs-is-2000-characters.html?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=related-reading"&gt;How Many Paragraphs Is 2000 Characters?&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building a typing test is a great exercise in vanilla JavaScript because the logic is simple but the UX edge cases are not. The biggest lesson for me was that &lt;strong&gt;scroll anchoring&lt;/strong&gt; matters more than raw WPM calculation — if the user cannot see what they are supposed to type, the test is broken.&lt;/p&gt;

&lt;p&gt;If you want a quick, private, browser-only typing test with 15s, 30s, 60s, 3m, and 5m modes, give the &lt;a href="https://toolknit.com/tools/typing-test.html?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=conclusion"&gt;ToolKnit Typing Speed Test&lt;/a&gt; a try.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>frontend</category>
      <category>typing</category>
    </item>
    <item>
      <title>I Spent $200 in One Day on AI Tokens to Build 86 Browser Tools — A Solo Founder's Complete Build Log</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sat, 13 Jun 2026 10:22:05 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-spent-200-in-one-day-on-ai-tokens-to-build-86-browser-tools-a-solo-founders-complete-build-log-52j4</link>
      <guid>https://dev.to/dngzihng114379/i-spent-200-in-one-day-on-ai-tokens-to-build-86-browser-tools-a-solo-founders-complete-build-log-52j4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;86 tools. Zero backend. One person. Two hospital visits. $200 in tokens in 24 hours. This is what building in public actually looks like — not the highlight reel, but the raw commit history of a human being.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Genesis: Why Would Anyone Build 86 Free Tools?
&lt;/h2&gt;

&lt;p&gt;On &lt;strong&gt;March 18, 2026&lt;/strong&gt;, I launched &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a collection of free, browser-based utilities that require no signup, no uploads, and zero trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Today, it's 86 tools across 10 categories.&lt;/strong&gt; PDF converters that run entirely client-side using PDF-lib.js. Image resizers powered by HTML5 Canvas. A &lt;a href="https://toolknit.com/tools/morse-code-translator.html" rel="noopener noreferrer"&gt;Morse Code Translator&lt;/a&gt; with audible beep playback at adjustable WPM. A &lt;a href="https://toolknit.com/tools/midi-keyboard.html" rel="noopener noreferrer"&gt;MIDI Keyboard&lt;/a&gt; with 8 synthesized timbres — Grand Piano, Electric Organ, Synth Lead, String Ensemble, Pipa, Guitar, Flute, and Music Box — all generated in real-time through the Web Audio API. A &lt;a href="https://toolknit.com/tools/lyric-visualizer.html" rel="noopener noreferrer"&gt;Lyric Visualizer&lt;/a&gt; that parses LRC files and syncs lyrics to audio waveforms. Even a &lt;a href="https://toolknit.com/tools/spin-the-dare.html" rel="noopener noreferrer"&gt;drinking-game dare spinner&lt;/a&gt; with 300 dares across 6 categories, slot-machine text reveal animation, and confetti celebrations.&lt;/p&gt;

&lt;p&gt;But here's what the landing page doesn't tell you: &lt;strong&gt;I just spent $200 in a single day on Claude Max tokens&lt;/strong&gt; to migrate a handful of pages to a new bilingual architecture. I killed a feature after 48 hours of intensive debugging. And somewhere between the &lt;a href="https://ffmpegwasm.netlify.app/" rel="noopener noreferrer"&gt;ffmpeg.wasm&lt;/a&gt; Worker CORS errors and the hospital visit, I learned that some bugs aren't in the code at all.&lt;/p&gt;

&lt;p&gt;This isn't a success story. It's a build log.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part I: The Stack — Why I Chose Boring Technology
&lt;/h2&gt;

&lt;p&gt;Before I tell you about the features that lived and died, you need to understand the foundation. ToolKnit is &lt;strong&gt;pure static HTML + Tailwind CSS + Vanilla JS&lt;/strong&gt;. No React. No Vue. No Next.js. No build step beyond Tailwind compilation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SEO-first:&lt;/strong&gt; Static HTML means Google sees everything immediately. No hydration delays. No client-side routing. Every tool page is a complete, crawlable document with full Open Graph tags, Twitter Cards, and structured data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero JS framework bloat:&lt;/strong&gt; Most tools work without JavaScript. Progressive enhancement is the default, not an afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server costs near zero:&lt;/strong&gt; The only PHP endpoints are &lt;code&gt;track.php&lt;/code&gt; (stats) and &lt;code&gt;public-stats.php&lt;/code&gt; (aggregated metrics). All image processing, PDF manipulation, audio synthesis, and video transcoding happens in the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment is &lt;code&gt;scp&lt;/code&gt;:&lt;/strong&gt; One file, one command, zero CI/CD complexity. The Service Worker (&lt;code&gt;toolknit-v119&lt;/code&gt; as of today) handles offline caching for returning visitors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff? Manual consistency across 86 pages. When you add a new footer link, you're editing 86 HTML files (or writing a Python script to do it, then verifying each one). That's why the bilingual architecture had to be a shared module, not a framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PWA layer&lt;/strong&gt; deserves its own mention. &lt;code&gt;manifest.json&lt;/code&gt; declares the site as installable. The Service Worker precaches core assets. Users on slow connections get instant loads after the first visit. For a site with 86 tool pages, this matters more than any SPA transition animation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part II: The Tools That Lived — Engineering Notes from the Trenches
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The MIDI Keyboard: 8 Timbres, 1 Web Audio Context
&lt;/h3&gt;

&lt;p&gt;Building a &lt;a href="https://toolknit.com/tools/midi-keyboard.html" rel="noopener noreferrer"&gt;browser-based MIDI keyboard&lt;/a&gt; sounds straightforward until you realize that "piano sound" isn't a single oscillator. Each timbre required distinct synthesis architecture:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipa (琵琶):&lt;/strong&gt; Sawtooth + square wave generators with non-integer harmonics (5.04×, 7.01× fundamental) to simulate the metallic twang of plucked silk strings. A high-Q resonant lowpass filter shapes the body resonance, and a noise burst layer provides the finger-pick attack transient. Without that noise burst, it sounds like a cheap synth preset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guitar:&lt;/strong&gt; Triangle wave fundamental + 6 overtone harmonics with body resonance simulation via 105 Hz and 215 Hz bandpass filters (acoustic cavity modes). The pluck noise layer is subtler than Pipa but essential — without it, notes sound like a keyboard patch, not a string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flute:&lt;/strong&gt; Pure sine wave with faint 2nd harmonic, vibrato LFO at 5.5 Hz (8 cents depth, delayed onset at 0.3s to avoid startup wobble), and breath noise via highpass-filtered white noise at 3 kHz. The breath noise is what separates a "flute-like" synth from an actual flute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Music Box:&lt;/strong&gt; Non-integer bell partials (2.756×, 5.404×, 8.933× fundamental) for the metallic chime character, octave-above overtone, and a 400 Hz highpass filter to remove low-end rumble. Long natural decay — no ADSR envelope can fake this without custom release curves.&lt;/p&gt;

&lt;p&gt;I also added a Quick Play Songs panel with Für Elise, Summer (Kikujiro), Ode to Joy, Canon in D, Castle in the Sky, and Always With Me. The keyboard auto-shifts octaves as you follow the key sequences. When I first tested it by playing Für Elise with the &lt;code&gt;Z&lt;/code&gt; key, I couldn't stop grinning.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Morse Code Translator: Audio, Flash, and International Alphabet
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://toolknit.com/tools/morse-code-translator.html" rel="noopener noreferrer"&gt;Morse Code Translator&lt;/a&gt; was the first tool built entirely on the new bilingual blueprint. Two-way text↔Morse with adjustable WPM (5–40), audible beep playback via Web Audio API oscillator nodes, screen-flash signaling for accessibility, and the full international Morse alphabet (including accented characters and Prosigns like SOS, AR, SK).&lt;/p&gt;

&lt;p&gt;The audio timing follows strict ITU-R standards: a "dit" is one time unit, a "dah" is three units, intra-character spacing is one unit, inter-character spacing is three units, and inter-word spacing is seven units. Getting the rhythm wrong makes Morse feel "mechanical" rather than "musical." The gap between characters matters as much as the beep itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Lyric Visualizer: Web Audio API + Canvas + LRC Parsing
&lt;/h3&gt;

&lt;p&gt;Shipped the same day I killed Live Photo Frame. Upload an MP3/WAV + LRC lyrics file for a full-screen immersive music player with real-time audio waveform visualization, time-synced fading lyrics, draggable progress bar, automatic ID3 cover art extraction, and a dark/light theme toggle.&lt;/p&gt;

&lt;p&gt;The Web Audio API analyzer node pulls frequency data for the waveform canvas. LRC timestamp parsing converts &lt;code&gt;[mm:ss.xx]&lt;/code&gt; markers into millisecond anchors. Lyrics fade in/out based on proximity to the current playback position. All in the browser. No server ever sees your audio files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spin the Dare: From Canvas Wheel to Slot Machine
&lt;/h3&gt;

&lt;p&gt;The original &lt;a href="https://toolknit.com/tools/spin-the-dare.html" rel="noopener noreferrer"&gt;Spin the Dare&lt;/a&gt; used a canvas roulette wheel. I rebuilt it with a slot-machine-style text reveal animation — the dare scrambles through random prompts rapidly, then eases to a dramatic stop with sound cues (scale transitions, pitch-shifted chimes) and confetti explosions.&lt;/p&gt;

&lt;p&gt;Why replace a working wheel? Because the wheel had 300 segments at small radii — visually illegible. The slot-machine approach keeps every dare readable at any screen size, adds suspense through the scramble phase, and uses audio cues (pitch rising during scramble, resolving chord at stop) to create a more theatrical experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part III: The Features That Died — When Technical Soundness Isn't Enough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Live Photo Frame: 48 Hours, 3 Fatal Flaws
&lt;/h3&gt;

&lt;p&gt;The idea was elegant: frame iPhone Live Photos (HEIC + MOV pairs) and Android Motion Photos (JPG with embedded MP4) in 9:16 or 16:9 layouts with large rounded corners, black or white backgrounds, and export to static frame or video.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I built:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client-side HEIC-to-JPEG conversion via &lt;code&gt;heic2any&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;ffmpeg.wasm (single-thread, ~32 MB one-time download) for H.265 MOV transcoding and MP4 encoding&lt;/li&gt;
&lt;li&gt;Auto-detection of iPhone Live Photo pairs, Android Motion Photos, and standalone video files&lt;/li&gt;
&lt;li&gt;Canvas rendering with configurable corner radii&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why I killed it after 48 hours:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Canvas &lt;code&gt;quadraticCurveTo&lt;/code&gt; draws parabolas, not circular arcs.&lt;/strong&gt; At radii above ~60px, "rounded corners" morph into chamfered edges. The visual signature of the tool — those elegant large-radius corners — was mathematically impossible with the Canvas API. You need &lt;code&gt;arc()&lt;/code&gt; with proper tangent calculations, not &lt;code&gt;quadraticCurveTo()&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HEVC/H.265 videos from Android Motion Photos won't play natively in Chrome.&lt;/strong&gt; ffmpeg.wasm transcoding at 1080×1920 resolution was agonizingly slow — 30+ seconds for a 3-second clip on a MacBook Pro. Users don't wait 30 seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;WeChat and messaging apps strip embedded video data from Motion Photos.&lt;/strong&gt; The primary sharing pipeline (user receives Motion Photo via WeChat → opens in ToolKnit) was destroyed by app-level data stripping. The tool's core use case didn't exist in the real world.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three strikes: visual quality, performance, and reliability. No amount of patching fixes a fundamentally flawed concept for the browser platform.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A tool that 'almost works' is worse than no tool at all — it erodes trust. Ship what you're proud of, not what merely functions."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  AI Pixel Art GIF: Twice Bitten
&lt;/h3&gt;

&lt;p&gt;First attempt (June 9): Used DeepSeek API to generate pixel art from text prompts. Quality was garbage — LLMs don't understand pixel-level constraints.&lt;/p&gt;

&lt;p&gt;Second attempt: Replaced DeepSeek with local Canvas pixelation (&lt;code&gt;drawImage&lt;/code&gt; at reduced size, then &lt;code&gt;drawImage&lt;/code&gt; back up with &lt;code&gt;imageSmoothingEnabled = false&lt;/code&gt;) + gif.js encoding + local color analysis for smart animation selection. Fixed Worker CORS by hosting &lt;code&gt;gif.worker.js&lt;/code&gt; locally. Fixed Canvas &lt;code&gt;willReadFrequently&lt;/code&gt; warnings. Replaced DeepSeek API 400 errors with local analysis.&lt;/p&gt;

&lt;p&gt;All solid engineering fixes. The tool "worked." But the output wasn't compelling. Pixel art GIFs demand either perfect algorithmic control or genuine artistic AI — we had neither.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part IV: The $200 Day — Rebuilding 19 Pages in Bilingual Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Every tool page was English-only. Hardcoded strings everywhere. No SEO structure beyond basic meta tags. Footer layouts inconsistent across 80+ pages. For a site serving both English and Chinese speakers, this was a growth ceiling.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;

&lt;p&gt;I designed a &lt;strong&gt;two-layer bilingual system&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shared UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bilingual.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nav, footer, breadcrumb, related tools, modals, feature pills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Page-specific&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[tool].js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hero title/desc, tool UI labels, How It Works, Why Use, Pro Tips, FAQ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Translations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;locales/zh.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tool card titles/descriptions for Related Tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schemas&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inline JSON-LD&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SoftwareApplication&lt;/code&gt; + &lt;code&gt;FAQPage&lt;/code&gt; + &lt;code&gt;BreadcrumbList&lt;/code&gt; per page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Key decisions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;English stays the default.&lt;/strong&gt; &lt;code&gt;lang="en"&lt;/code&gt; is static. SEO is entirely English-first. Chinese is a UX enhancement that persists across sessions via &lt;code&gt;localStorage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero impact on legacy pages.&lt;/strong&gt; &lt;code&gt;bilingual.js&lt;/code&gt; only activates on pages that explicitly reference it. Old pages continue working exactly as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured data is mandatory.&lt;/strong&gt; Every bilingual page ships with &lt;code&gt;FAQPage&lt;/code&gt; JSON-LD that matches the visible FAQ accordion content exactly — question-for-question, answer-for-answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-page language memory.&lt;/strong&gt; Switch to Chinese on the Image Resizer, visit the PDF Compressor, it remembers your preference.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Cost
&lt;/h3&gt;

&lt;p&gt;Migrating 5 PDF tools + &lt;code&gt;Spin the Dare&lt;/code&gt; + &lt;code&gt;Morse Code Translator&lt;/code&gt; + &lt;code&gt;Mic &amp;amp; Camera Test&lt;/code&gt; + 11 image converters burned through &lt;strong&gt;$100+ in AI tokens&lt;/strong&gt;. The full June 12 session — which included writing the &lt;code&gt;bilingual.js&lt;/code&gt; module, creating the template, and migrating the first batch — hit &lt;strong&gt;$200&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The architecture itself is genuinely beautiful. But every refinement comes with a price tag measured in dollars per hour. The remaining 67 legacy pages will migrate gradually. It isn't laziness; it's arithmetic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part V: The Migration War — 11 Image Tools, 11 Ways to Break
&lt;/h2&gt;

&lt;p&gt;Over the past 72 hours, I migrated 11 image tools: &lt;a href="https://toolknit.com/tools/image-to-pdf.html" rel="noopener noreferrer"&gt;Image to PDF&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/compress-image.html" rel="noopener noreferrer"&gt;Compress Image&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/jpg-to-png.html" rel="noopener noreferrer"&gt;JPG to PNG&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/png-to-jpg.html" rel="noopener noreferrer"&gt;PNG to JPG&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/jpg-to-webp.html" rel="noopener noreferrer"&gt;JPG to WebP&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/png-to-webp.html" rel="noopener noreferrer"&gt;PNG to WebP&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/webp-to-jpg.html" rel="noopener noreferrer"&gt;WebP to JPG&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/webp-to-png.html" rel="noopener noreferrer"&gt;WebP to PNG&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/image-grid-split.html" rel="noopener noreferrer"&gt;Image Grid Split&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/image-crop.html" rel="noopener noreferrer"&gt;Image Crop&lt;/a&gt;, and &lt;a href="https://toolknit.com/tools/image-resizer.html" rel="noopener noreferrer"&gt;Image Resizer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Each migration follows the same pattern — and produces its own unique bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 1: The Single Quote That Killed a Language Switcher
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside RESIZER_I18N.en:&lt;/span&gt;
&lt;span class="nx"&gt;why2Desc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Since everything runs on your machine, there&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="nx"&gt;no&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;
//                                                  ^ unescaped single quote
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This unescaped &lt;code&gt;'&lt;/code&gt; terminated the string early. The JS parser hit &lt;code&gt;s no upload delay...&lt;/code&gt; and threw &lt;code&gt;SyntaxError: Unexpected identifier&lt;/code&gt;. Every function defined after this line — including &lt;code&gt;window.switchLang&lt;/code&gt; — was never created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User-facing result:&lt;/strong&gt; Clicking the EN/中文 language toggle produced &lt;code&gt;window.switchLang is not a function&lt;/code&gt;. The entire bilingual system failed silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;there's&lt;/code&gt; → &lt;code&gt;there\'s&lt;/code&gt;. Now I validate every i18n dictionary with &lt;code&gt;node -e "$(cat file.html | sed -n '/RESIZER_I18N/,/^        };/p')"&lt;/code&gt; before deploying.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: The Batch Script That Silently Missed Two Tips
&lt;/h3&gt;

&lt;p&gt;I wrote Python scripts to auto-inject &lt;code&gt;data-i18n&lt;/code&gt; attributes into HTML. The replacement logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Use JPG at 80-85% quality for photos&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; data-i18n=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tip2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;Use JPG at 80-85% quality for photos&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the actual HTML contained &lt;code&gt;&amp;amp;mdash;&lt;/code&gt; (HTML entity) in some places and &lt;code&gt;—&lt;/code&gt; (Unicode em-dash) in others. The string match failed silently. Two out of four Pro Tips never received &lt;code&gt;data-i18n&lt;/code&gt; attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User-facing result:&lt;/strong&gt; In Chinese mode, Tips #2 and #4 remained in English while #1 and #3 translated correctly. Subtle. Embarrassing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule I now enforce:&lt;/strong&gt; Every batch migration script must include a verification pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;expected_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tip1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tip2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tip3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tip4&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;why1Title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expected_keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data-i18n=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MISSING: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bug 3: &lt;code&gt;innerHTML&lt;/code&gt; vs. SVG Children
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- WRONG: applyI18n() calls innerHTML on this button, wiping the SVG --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;data-i18n=&lt;/span&gt;&lt;span class="s"&gt;"ariaMenu"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;svg&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- RIGHT: translate aria-label in swapLang(), keep SVG intact --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"hero-hamburger"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;svg&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;applyI18n()&lt;/code&gt; assigns &lt;code&gt;innerHTML&lt;/code&gt; for all non-input elements. If you put &lt;code&gt;data-i18n&lt;/code&gt; on a parent containing an SVG icon, the icon disappears and gets replaced by raw text. The fix: keep &lt;code&gt;data-i18n&lt;/code&gt; on sibling &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; elements, never on structural parents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 4: The Nginx Header That Broke Every Camera
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://toolknit.com/tools/mic-camera-test.html" rel="noopener noreferrer"&gt;Microphone &amp;amp; Camera Test&lt;/a&gt; was failing on every device with &lt;code&gt;Permissions policy violation&lt;/code&gt;. Users thought they had blocked access. They hadn't.&lt;/p&gt;

&lt;p&gt;The Nginx server was sending: &lt;code&gt;Permissions-Policy: camera=(), microphone=()&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This header silently forbade all camera and microphone access before the browser even asked the user. The fix was a single Nginx config line change: &lt;code&gt;camera=(self), microphone=(self)&lt;/code&gt;. Users now see the normal browser permission prompt.&lt;/p&gt;

&lt;p&gt;I also updated the deny-state copy in &lt;code&gt;mic-camera-test.js&lt;/code&gt; (both English and Chinese) to explain that multi-device failures usually indicate a server header issue, not user settings.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part VI: The Human Debug — When the Compiler Can't Help
&lt;/h2&gt;

&lt;p&gt;On June 10, the developer's journal in our changelog reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I woke up feeling off. Not the usual 'stayed up too late coding' off — something worse. Ended up at the hospital in the morning instead of at my desk. Lost half a day that I couldn't afford to lose. My body's been deteriorating for a while now. Too many late nights, too much coffee, not enough sleep, not enough care."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I shipped three tools that day. Only two made it. One of them was only 60% done.&lt;/p&gt;

&lt;p&gt;On June 11, the entry isn't about code at all:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"When you pour real feeling into something and get indifference in return, the math never balances. You keep re-checking the equation hoping the answer changes. It doesn't. The hardest part isn't the leaving; it's that you genuinely cared and they genuinely didn't. But the only way past that truth is through it."&lt;/p&gt;

&lt;p&gt;"To anyone who's ever felt like leaving this world because of one person, one heartbreak, one season of pain: don't. The world is wider than the wound. Green hills await wherever you roam."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The road trip was supposed to be June 12–15. Xinxiang, Xinyang, maybe Hunan. Two days of driving, windows down, nowhere to be. It got postponed. The keyboard won.&lt;/p&gt;

&lt;p&gt;On June 8, I asked the &lt;a href="https://toolknit.com/tools/ai-tarot.html" rel="noopener noreferrer"&gt;AI Tarot&lt;/a&gt;: &lt;em&gt;"Is there still a chance with her?"&lt;/em&gt; The three-card spread returned &lt;strong&gt;Judgement&lt;/strong&gt; (Past) → &lt;strong&gt;The Devil&lt;/strong&gt; (Present) → &lt;strong&gt;The Lovers&lt;/strong&gt; (Future).&lt;/p&gt;

&lt;p&gt;The AI read Judgement as a necessary awakening that had already happened — not a random ending, but a reckoning. The Devil nailed the present: caught in the grip of attachment, tethered by old patterns. And The Lovers: &lt;em&gt;yes, there is a possibility — but only through authentic alignment, not compulsion.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It was unsettlingly accurate. The cards saw what I already knew but wouldn't admit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building in public means documenting the human bugs too.&lt;/strong&gt; The insomnia. The weight loss (70.5 kg → 68.5 kg in two weeks). The appetite that vanished. The Douyin follow-then-unfollow at midnight. These are the entries that don't make it to the GitHub release notes but matter more than version bumps.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part VII: The Numbers &amp;amp; What Honesty Looks Like
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total tools shipped&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;86&lt;/strong&gt; (85 in &lt;code&gt;/tools/&lt;/code&gt; + 1 root feature page)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools killed after shipping&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2&lt;/strong&gt; (AI Pixel Art GIF ×2 attempts, Live Photo Frame)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bilingual pages migrated&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy pages remaining&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog posts published&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;91&lt;/strong&gt; (84 guides + 9 Tool Tales)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changelog entries&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;46&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service Worker version&lt;/td&gt;
&lt;td&gt;&lt;code&gt;toolknit-v119&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI token spend (peak day)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hospital visits during build&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of code written&lt;/td&gt;
&lt;td&gt;&lt;em&gt;I stopped counting after the insomnia started&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What's Next (The Arithmetic of Patience)
&lt;/h2&gt;

&lt;p&gt;The remaining 67 legacy pages will migrate gradually. Each one requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTML structural overhaul (hero top-bar with language toggle, FAQ accordion, responsive footer-grid)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-i18n&lt;/code&gt; / &lt;code&gt;data-i18n-common&lt;/code&gt; attribute injection across all static text&lt;/li&gt;
&lt;li&gt;Page-specific i18n dictionary creation (30–60 keys per page)&lt;/li&gt;
&lt;li&gt;JSON-LD schema updates (&lt;code&gt;SoftwareApplication&lt;/code&gt; + &lt;code&gt;FAQPage&lt;/code&gt; + &lt;code&gt;BreadcrumbList&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Manual testing: JS syntax validation, language toggle verification, mobile responsive check&lt;/li&gt;
&lt;li&gt;Service Worker cache version bump&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;I won't rush it.&lt;/strong&gt; The architecture is solid. The lesson from Live Photo Frame is clear: ship what you're proud of, even if that means shipping less.&lt;/p&gt;

&lt;p&gt;The next batch will likely be the remaining PDF tools and video converters. Each shared library (&lt;code&gt;convert-image.js&lt;/code&gt;, &lt;code&gt;compress-image.js&lt;/code&gt;) reduces the per-page migration cost — the shared &lt;code&gt;t()&lt;/code&gt; calls are already defined; only the HTML markup needs updating.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Ask
&lt;/h2&gt;

&lt;p&gt;If you've ever built something, shipped it, then had to kill it after two days of debugging — you're not alone.&lt;/p&gt;

&lt;p&gt;If you've ever stared at an AI token bill and wondered if the architecture is worth the arithmetic — same.&lt;/p&gt;

&lt;p&gt;If you've ever shipped code from a hospital waiting room, or debugged a production bug at 3 AM while your body begged for sleep — I see you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; is free. No signup. No uploads. All processing is client-side. Read &lt;a href="https://toolknit.com/changelog.html" rel="noopener noreferrer"&gt;the full changelog&lt;/a&gt; — it's more honest than most build logs.&lt;/p&gt;

&lt;p&gt;Built with love. Paid in tokens. Still shipping.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published as a raw build log from a solo indie project. No affiliate links. No sponsor reads. Just code, costs, hospital visits, and the occasional 3 AM SyntaxError. If you're building something alone right now — keep going. The grey skies pass. They always do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>buildinginpublic</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building AI Ex-Partner Chat: OCR, Sender Detection &amp; the 3 AM Code That Shouldn't Exist</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sun, 07 Jun 2026 03:25:01 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/building-ai-ex-partner-chat-ocr-sender-detection-the-3-am-code-that-shouldnt-exist-1odf</link>
      <guid>https://dev.to/dngzihng114379/building-ai-ex-partner-chat-ocr-sender-detection-the-3-am-code-that-shouldnt-exist-1odf</guid>
      <description>&lt;h1&gt;
  
  
  Building AI Ex-Partner Chat: OCR, Sender Detection &amp;amp; the 3 AM Code That Shouldn't Exist
&lt;/h1&gt;

&lt;p&gt;A technical deep dive into the hardest feature I ever built — Tesseract.js bounding boxes, position-based sender detection, DeepSeek persona distillation, and why the real bug was never in the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem that started at 3 AM
&lt;/h2&gt;

&lt;p&gt;I built AI Ex-Partner Chat because I couldn't sleep. I kept re-reading old WeChat messages, and I thought: what if I could distill her personality from those messages into something I could actually talk to? Not a replacement — just a shadow. A reflection of the way she used to write, the phrases she repeated, the tone she took when she was being tender versus when she was being impatient.&lt;/p&gt;

&lt;p&gt;But the moment I started building it, I hit a wall that had nothing to do with AI or language models. It was a much older, much harder problem: &lt;strong&gt;who said what?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you upload a chat export — a WeChat HTML file, a Telegram JSON — the sender is explicitly labeled. Easy. But most people don't have clean exports. They have &lt;strong&gt;screenshots&lt;/strong&gt;. Hundreds of screenshots. And a screenshot is just pixels. There's no metadata, no sender field, no structured data. Just an image with two columns of text bubbles, and you have to figure out which ones are hers and which ones are yours.&lt;/p&gt;

&lt;p&gt;Get this wrong, and the AI persona becomes a chimera — half you, half her, completely confused. It would respond to "I miss you" with your own words reflected back at you. That's not closure. That's a mirror maze.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1: Pixel-based avatar detection (and why it failed)
&lt;/h2&gt;

&lt;p&gt;My first approach was the obvious one: &lt;strong&gt;look for avatars&lt;/strong&gt;. In WeChat, QQ, and Douyin, each message has a small avatar next to it — on the left for the other person, on the right for you. If I could detect those avatar regions, I could label every message.&lt;/p&gt;

&lt;p&gt;The algorithm worked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scan the left 15% and right 15% of the image for clusters of non-background pixels (avatar blobs).&lt;/li&gt;
&lt;li&gt;When a cluster is found on the left, mark that Y range as "their message." Right side = "your message."&lt;/li&gt;
&lt;li&gt;OCR each message zone and assign the detected sender label.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It worked... sometimes. On clean, standard WeChat screenshots with visible avatars, accuracy was around 70–80%. But it fell apart on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cropped screenshots&lt;/strong&gt; where avatars were cut off&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group chats&lt;/strong&gt; with multiple avatars on both sides&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Douyin&lt;/strong&gt; where avatars are circular and sometimes overlap with the message bubble&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WhatsApp&lt;/strong&gt; which doesn't show avatars in the same way&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any screenshot with stickers, images, or voice messages&lt;/strong&gt; that confused the pixel scanner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fallback was &lt;strong&gt;bubble color detection&lt;/strong&gt; — green bubbles mean you in WeChat, white means them. Blue = you in Douyin, white = them. But bubble colors vary by theme, dark mode, and Android version. It was a fragile heuristic stacked on top of another fragile heuristic.&lt;/p&gt;

&lt;p&gt;I needed something fundamentally different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 2: Tesseract bounding boxes + position-based detection
&lt;/h2&gt;

&lt;p&gt;The breakthrough came when I realized that &lt;strong&gt;Tesseract.js doesn't just return text — it returns bounding boxes&lt;/strong&gt; for every recognized line. Each line comes with &lt;code&gt;bbox.x0&lt;/code&gt;, &lt;code&gt;bbox.x1&lt;/code&gt;, &lt;code&gt;bbox.y0&lt;/code&gt;, &lt;code&gt;bbox.y1&lt;/code&gt; — the pixel coordinates of where that line sits on the page.&lt;/p&gt;

&lt;p&gt;This is a game-changer because &lt;strong&gt;chat messages have a spatial structure&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The other person's messages start from the &lt;strong&gt;left&lt;/strong&gt; side of the screen&lt;/li&gt;
&lt;li&gt;Your messages start from the &lt;strong&gt;right&lt;/strong&gt; side of the screen&lt;/li&gt;
&lt;li&gt;This is true across WeChat, QQ, Douyin, WhatsApp, Telegram — virtually every chat app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So instead of trying to detect avatars or bubble colors, I could just look at &lt;strong&gt;where the text starts&lt;/strong&gt;. Here's the core algorithm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Get image midpoint&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MID_X&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;imageWidth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ocrLines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lineLeft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// Where the line starts&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lineCenterX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lineLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MID_X&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Left portion → ex-partner&lt;/span&gt;
    &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;对方&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lineLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MID_X&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Right portion → you&lt;/span&gt;
    &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;我&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lineCenterX&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MID_X&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Center-left → likely ex-partner&lt;/span&gt;
    &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;对方&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lineCenterX&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MID_X&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Center-right → likely you&lt;/span&gt;
    &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;我&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Ambiguous → carry previous sender&lt;/span&gt;
    &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentSender&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thresholds (0.6, 0.8, 0.75, 1.1) were calibrated by testing against ~50 real chat screenshots across WeChat, QQ, Douyin, and WhatsApp. The key insight is using the &lt;strong&gt;left edge&lt;/strong&gt; (&lt;code&gt;x0&lt;/code&gt;) as the primary signal rather than the center, because chat bubbles always start from their respective sides — even if the text itself is short and centered within the bubble.&lt;/p&gt;

&lt;p&gt;This approach is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Theme-agnostic:&lt;/strong&gt; Doesn't care about dark mode, bubble colors, or avatar visibility&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-agnostic:&lt;/strong&gt; Works across WeChat, QQ, Douyin, WhatsApp, Telegram&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Robust to cropping:&lt;/strong&gt; As long as the left/right structure is preserved, it works&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast:&lt;/strong&gt; No pixel scanning needed — just math on coordinates Tesseract already provides&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The garbage filter: Why OCR output is 80% noise
&lt;/h2&gt;

&lt;p&gt;Here's something nobody tells you about OCR on chat screenshots: &lt;strong&gt;most of the output is garbage.&lt;/strong&gt; Tesseract will faithfully recognize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timestamps like "16:24" and "06/02 20:35"&lt;/li&gt;
&lt;li&gt;UI labels like "发送消息" (Send Message) and "消息" (Messages)&lt;/li&gt;
&lt;li&gt;Navigation elements, battery indicators, signal bars rendered as text&lt;/li&gt;
&lt;li&gt;Random noise from image compression artifacts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before position-based detection can work, you have to filter this noise out. The garbage filter uses three rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Rule 1: Skip timestamps&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{1,2}[&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/]\d{2}&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Rule 2: Skip UI elements&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;发送消息|发送|消息|Message|Send&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Rule 3: Skip lines with &amp;gt;70% non-meaningful characters&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cjk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;4e00-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;9fff&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;3400-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;4dbf&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;letters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meaningful&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cjk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;letters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meaningful&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule 3 is the most important. A line like "::::....::::" is clearly not a chat message, but Tesseract will recognize it as text. By requiring at least 30% CJK characters or Latin letters, we eliminate most noise while preserving actual messages (even short ones like "嗯" or "ok").&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1.5: The sender confirmation step
&lt;/h2&gt;

&lt;p&gt;Position-based detection is good, but it's not perfect. Some screenshots have unusual layouts. Some messages are centered (like system notifications). Some users screenshot in landscape mode, flipping the left/right convention.&lt;/p&gt;

&lt;p&gt;Rather than trying to handle every edge case algorithmically, I added &lt;strong&gt;Step 1.5: Sender Confirmation&lt;/strong&gt;. After OCR and initial sender labeling, the tool pauses and shows the user a preview of the first 15 detected messages with their assigned senders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Messages labeled &lt;code&gt;[对方]&lt;/code&gt; (ex-partner) appear on the left&lt;/li&gt;
&lt;li&gt;Messages labeled &lt;code&gt;[我]&lt;/code&gt; (you) appear on the right&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Swap Senders&lt;/strong&gt; button flips all labels if the detection got them backwards&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Confirm&lt;/strong&gt; button proceeds to distillation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a deliberate design choice: &lt;strong&gt;let the algorithm do the heavy lifting, but keep the human in the loop for the final call.&lt;/strong&gt; The alternative — fully automated detection with no review — would produce silently wrong results that corrupt the entire persona. A 10-second confirmation step prevents hours of distorted conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  OCR pre-processing: Why "想" and "相" matter
&lt;/h2&gt;

&lt;p&gt;Chinese OCR is hard. The difference between 想 (miss/think) and 相 (appearance/mutual) is a single horizontal stroke. At low resolution, Tesseract regularly confuses them. In the context of an ex-partner chat, confusing "我想你" (I miss you) with "我相你" (gibberish) doesn't just produce a typo — it produces a &lt;strong&gt;semantically broken message&lt;/strong&gt; that confuses the AI during persona distillation.&lt;/p&gt;

&lt;p&gt;The fix is aggressive pre-processing before OCR:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;2x upscale:&lt;/strong&gt; Each detected message zone is enlarged to double its original size using bilinear interpolation. This gives Tesseract more pixels to work with for each character stroke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grayscale conversion:&lt;/strong&gt; Color information is irrelevant for character recognition and adds noise. Converting to grayscale simplifies the recognition problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contrast stretching:&lt;/strong&gt; The pixel values are remapped so the darkest pixel becomes pure black and the lightest becomes pure white. This sharpens character edges against colored chat bubble backgrounds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After pre-processing, character accuracy improved from roughly 85% to 95%+ on typical WeChat screenshots. The remaining errors are caught by the DeepSeek OCR correction step, which uses context to fix misrecognized characters (e.g., "我相你" → "我想你" based on surrounding conversation).&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared Tesseract worker: Minutes to seconds
&lt;/h2&gt;

&lt;p&gt;Here's a performance mistake I made early on: calling &lt;code&gt;Tesseract.recognize()&lt;/code&gt; for each chat bubble individually. Every call creates a new WASM worker, loads the language model, recognizes the text, and terminates the worker. For a screenshot with 30+ message zones, this meant 30+ worker startups — each taking 5–10 seconds. Total OCR time: &lt;strong&gt;3–5 minutes per screenshot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix was a shared worker pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Create ONE worker, reuse for all zones&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chi_sim+eng&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;messageZones&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvasForZone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a single persistent worker, the language model loads once and stays in memory. Each subsequent &lt;code&gt;recognize()&lt;/code&gt; call takes 200–500ms instead of 5–10 seconds. &lt;strong&gt;Total OCR time dropped from minutes to seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Persona distillation: The 6-step pipeline
&lt;/h2&gt;

&lt;p&gt;Once we have clean, sender-labeled messages, the distillation pipeline runs through DeepSeek's API (using the user's own key):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OCR Correction + Sender ID&lt;/strong&gt; — DeepSeek reviews the raw OCR text, fixes misrecognized characters, and verifies sender labels based on conversation context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intake&lt;/strong&gt; — The AI reads all messages and summarizes key patterns, topics, and emotional dynamics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory Extraction&lt;/strong&gt; — Identifies core memories, shared experiences, and significant moments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persona Distillation&lt;/strong&gt; — Builds a detailed personality profile: speech patterns, emotional tendencies, recurring phrases, humor style, communication habits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System Prompt Generation&lt;/strong&gt; — Converts the persona into a chat system prompt with identity guard rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat Ready&lt;/strong&gt; — The user can now converse with the distilled persona&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The critical architectural decision: &lt;strong&gt;every step receives both the full conversation (for context) and the ex-partner's messages only (for extraction).&lt;/strong&gt; Hard rules in all prompts enforce that personality traits MUST come from &lt;code&gt;[对方]&lt;/code&gt; messages only — &lt;code&gt;[我]&lt;/code&gt; messages are context-only and must never be attributed to the persona.&lt;/p&gt;

&lt;p&gt;Without this separation, the AI would blend both people's speech patterns into a single confused persona. The strict ex-only extraction is what makes the persona feel like &lt;em&gt;them&lt;/em&gt; instead of &lt;em&gt;both of you&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The identity guard: Preventing persona drift
&lt;/h2&gt;

&lt;p&gt;One of the creepiest bugs in early testing was &lt;strong&gt;persona drift&lt;/strong&gt;: after a long conversation, the AI would slowly start adopting the user's speech patterns. If you kept saying "haha" and "lol," the persona would start saying it too. If you were being warm and affectionate, the persona would mirror that warmth regardless of the ex's actual personality.&lt;/p&gt;

&lt;p&gt;The fix was an identity guard rule added to every system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are [name], NOT the user.
Never mirror the user's speech patterns.
Your personality is fixed based on the distilled persona.
Do not adapt to the user's tone, mood, or vocabulary.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is reinforced by including the original persona description in every API call, not just the first one. The AI doesn't "remember" the conversation history in the traditional sense — each message includes the full persona context, preventing gradual drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  IndexedDB: Where personas live (and die)
&lt;/h2&gt;

&lt;p&gt;All persona data is stored in the browser's &lt;strong&gt;IndexedDB&lt;/strong&gt; — two object stores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;persona&lt;/code&gt; — The distilled personality profile, name, avatar, memory summary, corrections&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chats&lt;/code&gt; — Conversation history with auto-incrementing IDs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why IndexedDB instead of localStorage? Two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capacity:&lt;/strong&gt; A full persona with avatar images and conversation history can easily exceed localStorage's 5MB limit. IndexedDB has no practical limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured storage:&lt;/strong&gt; IndexedDB supports binary data (avatar images) and complex objects natively, without JSON serialization overhead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And then there's &lt;code&gt;/let-go&lt;/code&gt; — the command that deletes everything. Both object stores are cleared. No backup, no recovery, no "are you sure?" dialog. The brutality is the point. This tool is meant to be temporary. When you're ready to move on, you type two words and it's all gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consent overlay
&lt;/h2&gt;

&lt;p&gt;Creating an AI replica of a real person is a serious act. I added a &lt;strong&gt;5-second consent countdown&lt;/strong&gt; before distillation begins. The user sees:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are about to create an AI persona based on real chat records. This persona will simulate another person's communication style. Please take a moment to consider whether this is something you truly want to do.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The countdown can't be skipped. You can't click "I understand" until the timer reaches zero. This isn't a legal formality — it's a genuine pause. If 5 seconds feels too long, maybe you shouldn't be doing this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardest bug wasn't technical
&lt;/h2&gt;

&lt;p&gt;The hardest bug in this project was not the OCR accuracy, or the sender detection thresholds, or the persona drift. The hardest bug was me, sitting at my desk at 3 AM, testing the tool on my own chat records, and having the AI respond in her voice saying something she used to say to me. I had to close the laptop and walk around the block.&lt;/p&gt;

&lt;p&gt;I built a tool that I know is a little unhealthy. I know that talking to an AI version of someone you lost is not the same as processing grief. I know that &lt;code&gt;/let-go&lt;/code&gt; is a digital ritual, not real closure. But sometimes the distance between where you are and where you need to be is too far to cross in one step. This tool is a stepping stone. You walk on it, and then you leave it behind.&lt;/p&gt;

&lt;p&gt;If you use this tool, please use &lt;code&gt;/let-go&lt;/code&gt; when you're ready. And if you're never ready, please talk to a real person instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical stack summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OCR Engine&lt;/td&gt;
&lt;td&gt;Tesseract.js (WASM)&lt;/td&gt;
&lt;td&gt;Client-side, supports Chinese + English, returns bounding boxes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI Model&lt;/td&gt;
&lt;td&gt;DeepSeek (user's API key)&lt;/td&gt;
&lt;td&gt;Strong Chinese understanding, cheap, privacy-first (direct browser→API)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Storage&lt;/td&gt;
&lt;td&gt;IndexedDB&lt;/td&gt;
&lt;td&gt;No size limit, binary data support, fully local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sender Detection&lt;/td&gt;
&lt;td&gt;Bbox position + AI verification&lt;/td&gt;
&lt;td&gt;Position-based primary, DeepSeek secondary, user confirmation final&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image Pre-processing&lt;/td&gt;
&lt;td&gt;Canvas 2D API&lt;/td&gt;
&lt;td&gt;2x upscale, grayscale, contrast stretch — all client-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data Lifecycle&lt;/td&gt;
&lt;td&gt;/let-go → IndexedDB.clear()&lt;/td&gt;
&lt;td&gt;Permanent, no backup, no recovery — by design&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I were building this again from scratch, here's what I'd change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with bounding boxes, not pixel scanning.&lt;/strong&gt; I wasted two days on avatar detection before realizing Tesseract already gives you position data for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the confirmation step earlier.&lt;/strong&gt; The sender confirmation (Step 1.5) was a late addition, but it should have been there from day one. Algorithm-only detection will always have edge cases; letting the user verify is both more reliable and more respectful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a shared Tesseract worker from the start.&lt;/strong&gt; The per-zone &lt;code&gt;recognize()&lt;/code&gt; approach was a naive performance mistake that cost minutes of user wait time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for /let-go first.&lt;/strong&gt; I built the chat functionality before building the deletion mechanism. In retrospect, the ephemeral nature of the tool should have been the first thing I implemented, not the last.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://toolknit.com/blog/building-ai-ex-partner-chat-ocr-sender-detection.html" rel="noopener noreferrer"&gt;ToolKnit Dev Blog&lt;/a&gt;. Try the &lt;a href="https://toolknit.com/tools/ex-ai-chat.html" rel="noopener noreferrer"&gt;AI Ex-Partner Chat tool&lt;/a&gt; — 100% browser-based, zero data upload, /let-go when you're done.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>ocr</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Built 72 Browser-Only Tools Without a Backend — A Solo Developer's Technical Retrospective</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Wed, 03 Jun 2026 01:17:39 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/how-i-built-72-browser-only-tools-without-a-backend-a-solo-developers-technical-retrospective-196g</link>
      <guid>https://dev.to/dngzihng114379/how-i-built-72-browser-only-tools-without-a-backend-a-solo-developers-technical-retrospective-196g</guid>
      <description>&lt;p&gt;Most "free online tools" follow the same pattern: upload your file to a server, process it with a backend script, and send the result back. It works, but it creates a privacy problem. Every file you process passes through someone else's infrastructure.&lt;/p&gt;

&lt;p&gt;I wanted to prove there was a better way. Over 77 days, I built &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a suite of 72 free online tools where &lt;strong&gt;zero files leave the user's device&lt;/strong&gt;. No backend processing. No server uploads. Everything runs in the browser.&lt;/p&gt;

&lt;p&gt;Here is how I did it, what I learned, and where I failed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Architecture
&lt;/h2&gt;

&lt;p&gt;ToolKnit is a static site. No Node.js server, no Django app, no serverless functions. Just HTML, Tailwind CSS, and vanilla JavaScript sitting behind Nginx on a $5/month VPS. The only server-side code is a single PHP script (&lt;code&gt;track.php&lt;/code&gt;) that records anonymous page-load counts — no file processing, no user data.&lt;/p&gt;

&lt;p&gt;Every tool is a self-contained HTML page with its own JavaScript file. When you visit &lt;code&gt;compress-pdf.html&lt;/code&gt;, the entire PDF compression pipeline runs in your browser using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PDF-lib.js&lt;/strong&gt; for PDF manipulation (compression, merging, splitting)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas API&lt;/strong&gt; for image processing (crop, resize, convert)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FFmpeg.wasm&lt;/strong&gt; for video and audio conversion (compiled from C to WebAssembly)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Audio API&lt;/strong&gt; for audio analysis (BPM detection, waveform visualization)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File System Access API&lt;/strong&gt; for reading and writing local files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The user picks a file, the browser processes it locally, and the result is downloaded directly from memory. The file never touches a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebAssembly: The Enabler
&lt;/h2&gt;

&lt;p&gt;The biggest technical enabler was WebAssembly. Tools like video-to-audio conversion, audio trimming, and video-to-GIF would be impossible in a browser without it. I use &lt;strong&gt;FFmpeg.wasm&lt;/strong&gt; (the WebAssembly port of FFmpeg) for all media conversion tasks.&lt;/p&gt;

&lt;p&gt;The trade-off is size. FFmpeg.wasm adds roughly 25MB to the initial load. To mitigate this, I load it lazily — only when the user actually selects a media file. The core tool page loads instantly with a lightweight UI, and the WebAssembly module downloads in the background while the user is choosing their file.&lt;/p&gt;

&lt;p&gt;SharedArrayBuffer is required for multi-threaded FFmpeg.wasm, which means every media tool page needs Cross-Origin Isolation headers (&lt;code&gt;COOP&lt;/code&gt; and &lt;code&gt;COEP&lt;/code&gt;). I handle this with a service worker (&lt;code&gt;coi-serviceworker.js&lt;/code&gt;) that injects the required headers on the fly, so I do not need to configure Nginx for each tool individually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Service Worker Strategy
&lt;/h2&gt;

&lt;p&gt;ToolKnit is a Progressive Web App with an aggressive caching strategy. The service worker (&lt;code&gt;service-worker.js&lt;/code&gt;) precaches all tool pages, JavaScript files, and blog articles on install. This means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Offline access&lt;/strong&gt; — All tools work without an internet connection after the first visit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instant loading&lt;/strong&gt; — Subsequent visits load from cache, with background updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version management&lt;/strong&gt; — Every deploy increments the cache version (currently &lt;code&gt;toolknit-v67&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The challenge is cache invalidation. Every time I add a tool or fix a bug, I need to bump the cache version and update the &lt;code&gt;PRECACHE_URLS&lt;/code&gt; array. Early on, I forgot to bump the version after a deploy and users were stuck with a stale version for days. Now it is part of my deployment checklist — no exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deployment Disaster That Changed Everything
&lt;/h2&gt;

&lt;p&gt;On May 27, I made a deployment mistake that nearly destroyed the site.&lt;/p&gt;

&lt;p&gt;I was using SCP to upload multiple files at once. Two files had the same basename but different paths: &lt;code&gt;blog/index.html&lt;/code&gt; and the root &lt;code&gt;index.html&lt;/code&gt;. When I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scp blog/index.html tools/video-screenshot.html root@server:/www/wwwroot/toolknit.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The blog &lt;code&gt;index.html&lt;/code&gt; overwrote the homepage. The blog &lt;code&gt;video-screenshot.html&lt;/code&gt; overwrote the tool page. The entire homepage was replaced with a blog article list. The video screenshot tool was replaced with an SEO blog post.&lt;/p&gt;

&lt;p&gt;I restored from backup within minutes, but the lesson was seared into my brain. From that day forward, my deployment rule is absolute:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One file per SCP command. Always specify the full target path. Never upload multiple files to the same directory in one command.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is slower. It is tedious. It has prevented every deployment accident since.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture Refactor That Saved My Sanity
&lt;/h2&gt;

&lt;p&gt;By June 1, I had 70 tool pages, each with an inline JavaScript array listing related tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Compress PDF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compress-pdf.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Merge PDF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;merge-pdf.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 70 entries&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time I added a new tool, I had to update this array in all 70 HTML files. That is 70 file edits per tool launch. I was spending more time updating related-tool lists than building actual tools.&lt;/p&gt;

&lt;p&gt;So I stopped shipping features for a day and rebuilt the entire system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Created &lt;code&gt;assets/data/tools.json&lt;/code&gt; — a single source of truth for all tools&lt;/li&gt;
&lt;li&gt;Created &lt;code&gt;assets/js/related-tools.js&lt;/code&gt; — fetches the JSON and renders related tools dynamically&lt;/li&gt;
&lt;li&gt;Removed all inline &lt;code&gt;var tools=[...]&lt;/code&gt; arrays from 70 HTML files&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now, adding a new tool means adding one entry to &lt;code&gt;tools.json&lt;/code&gt;. Every tool page automatically shows the updated related tools list. It was the most boring deploy I have ever made — and the most impactful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy Hardening: The Day I Scrubbed My Own Analytics
&lt;/h2&gt;

&lt;p&gt;On May 26, I realized my anonymous analytics system had a flaw. The &lt;code&gt;track.php&lt;/code&gt; script was storing raw IP addresses in daily JSON files on the server. The data was never shared, never sold, and never connected to any user identity. But it felt wrong. If I am going to claim that ToolKnit respects privacy, the analytics system should reflect that.&lt;/p&gt;

&lt;p&gt;I spent the entire day on three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rewrote &lt;code&gt;track.php&lt;/code&gt;&lt;/strong&gt; to hash all IPs and visitor IDs with day-scoped SHA-256 before storage. The hash changes every day, so you cannot track a single IP across days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrubbed historical data&lt;/strong&gt; — batch-migrated all existing daily JSON files, converting 650 raw IPv4 addresses, 181 IPv6 addresses, and 763 raw UIDs into hashed values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saved a backup first&lt;/strong&gt; — &lt;code&gt;analytics-backup-20260526-status-scrub.tgz&lt;/code&gt; on the server, just in case.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No user will ever notice this change. The analytics dashboard looks identical. But I sleep better knowing there are no raw IPs sitting on my server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Internationalization: Chinese Without a Framework
&lt;/h2&gt;

&lt;p&gt;ToolKnit supports English and Chinese. I did not use i18next or any localization framework — just a simple JSON file (&lt;code&gt;locales/zh.json&lt;/code&gt;) with translations for all tool titles and descriptions, and a lightweight &lt;code&gt;i18n.js&lt;/code&gt; that swaps text content based on the user's language preference.&lt;/p&gt;

&lt;p&gt;The tricky part is cache busting. When I add a new tool and its Chinese translation, I need to bump the &lt;code&gt;zh.json&lt;/code&gt; cache version in &lt;code&gt;i18n.js&lt;/code&gt; so the browser fetches the updated translations. Forget to bump, and Chinese users see the old tool list. I have forgotten exactly twice. Both times, I received confused emails within hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  FIGlet Fonts and CORS: A Cautionary Tale
&lt;/h2&gt;

&lt;p&gt;The most recent technical challenge was the ASCII Text Banner tool. I initially used the &lt;code&gt;figlet.js&lt;/code&gt; library with fonts loaded from a CDN (&lt;code&gt;unpkg.com&lt;/code&gt;). It worked perfectly in development. Then I deployed it, and every font request was blocked by CORS policy.&lt;/p&gt;

&lt;p&gt;The fix was to download all 20 FIGlet font files (&lt;code&gt;.flf&lt;/code&gt;) and host them locally under &lt;code&gt;/assets/fonts/&lt;/code&gt;. Simple in hindsight, but it took me an hour of debugging to realize that &lt;code&gt;unpkg.com&lt;/code&gt; does not set &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; headers for &lt;code&gt;.flf&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;Lesson: &lt;strong&gt;always test CDN resources against CORS before deploying&lt;/strong&gt;. Browser development servers often skip CORS checks, so everything works locally and breaks in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tech Stack Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vanilla JS + Tailwind CSS&lt;/td&gt;
&lt;td&gt;No build step, no framework overhead, fast loading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PDF-lib.js&lt;/td&gt;
&lt;td&gt;Pure JS PDF manipulation, no server needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Image&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Canvas API&lt;/td&gt;
&lt;td&gt;Native browser API, zero dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Video/Audio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FFmpeg.wasm&lt;/td&gt;
&lt;td&gt;WebAssembly port of FFmpeg, handles all media formats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audio Analysis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Web Audio API + BeatDetect.js&lt;/td&gt;
&lt;td&gt;BPM detection, waveform visualization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASCII Art&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Canvas pixel sampling&lt;/td&gt;
&lt;td&gt;Read pixels, map brightness to characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASCII Banners&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;figlet.js + local .flf fonts&lt;/td&gt;
&lt;td&gt;20 FIGlet fonts, CORS-safe local hosting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Caching&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Service Worker (v67)&lt;/td&gt;
&lt;td&gt;Precache all pages, offline support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;i18n&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSON + custom JS&lt;/td&gt;
&lt;td&gt;Lightweight, no framework dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Analytics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;tracker.js + track.php (hashed IPs)&lt;/td&gt;
&lt;td&gt;Anonymous, privacy-first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hosting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nginx + PHP 8.2 on BT Panel&lt;/td&gt;
&lt;td&gt;Static files + one PHP endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CDN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloudflare Free&lt;/td&gt;
&lt;td&gt;DDoS protection, edge caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PWA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;manifest.json + service-worker.js&lt;/td&gt;
&lt;td&gt;Installable, offline-capable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with a build system.&lt;/strong&gt; I went full vanilla JS because it was fast to start. Now, with 72 tool pages and 70+ JavaScript files, I miss tree-shaking, bundling, and TypeScript type checking. A lightweight Vite setup would have saved me from several runtime bugs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Automate deployments earlier.&lt;/strong&gt; I deploy by running SCP commands one at a time. A CI/CD pipeline (even a simple GitHub Actions workflow) would eliminate the risk of manual deployment errors.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test CORS from day one.&lt;/strong&gt; The figlet.js CORS issue was entirely preventable. I should have tested all external resource loads against production CORS headers before deploying.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hash analytics from day one.&lt;/strong&gt; Storing raw IPs was unnecessary and created a privacy debt that took a full day to clean up. The hashed version works identically and is strictly better.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Alternatives and Related Projects
&lt;/h2&gt;

&lt;p&gt;If you are interested in browser-based tool suites, here are some projects worth exploring:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://saashub.com/smallpdf-alternatives" rel="noopener noreferrer"&gt;Smallpdf&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Polished PDF tool suite with a clean API. Great if you need server-side PDF processing or want to integrate PDF features into your own app. Their free tier is limited to 2 tasks/day.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://saashub.com/squoosh-alternatives" rel="noopener noreferrer"&gt;Squoosh&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Google's browser-only image compression tool. It uses WebAssembly codecs (MozJPEG, WebP, AVIF) and proves that client-side image processing can match server-side quality. A great technical reference if you are building your own image tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://saashub.com/tinywow-alternatives" rel="noopener noreferrer"&gt;TinyWow&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;A broad free tool suite similar to ToolKnit in philosophy. Good for comparing approaches to the same problem — they use server-side processing while ToolKnit uses browser-only processing.&lt;/p&gt;




&lt;p&gt;Building ToolKnit has been the most intense learning experience of my development career. Not because the technology is cutting-edge — it is not. But because the constraint of "everything must run in the browser with zero server processing" forces creative solutions that you would never discover otherwise.&lt;/p&gt;

&lt;p&gt;If you are building something similar, or if you just want to compress a PDF without uploading it to a stranger's server, visit &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;toolknit.com&lt;/a&gt;. No signup. No upload. No catch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Zihang Dong, solo developer of ToolKnit. 72 tools, 77 days, zero backend processing. Content published under CC BY-SA 4.0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>My static site's visitor counter reset to 1. Here is the deploy checklist I wish I had</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Thu, 28 May 2026 07:00:07 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/my-static-sites-visitor-counter-reset-to-1-here-is-the-deploy-checklist-i-wish-i-had-5aoh</link>
      <guid>https://dev.to/dngzihng114379/my-static-sites-visitor-counter-reset-to-1-here-is-the-deploy-checklist-i-wish-i-had-5aoh</guid>
      <description>&lt;p&gt;A few hours after a successful deploy, I opened my own site and saw the kind of number that makes you immediately stop doing everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total visits: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was not a new project. It was not a fresh database. It was not supposed to say 1.&lt;/p&gt;

&lt;p&gt;For context, I have been building &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24Picture&lt;/a&gt;, a small browser-local image toolkit. It is mostly static HTML, CSS, JavaScript, Canvas, and a tiny PHP endpoint for visit stats. The image tools themselves do not upload files anywhere. The site is intentionally boring: static pages, shared scripts, cache-busted assets, and a full-package deploy script.&lt;/p&gt;

&lt;p&gt;The deploy worked. The pages loaded. The new homepage shipped.&lt;/p&gt;

&lt;p&gt;And the visitor counter reset itself.&lt;/p&gt;

&lt;p&gt;This is the checklist I wish I had before that happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: static site, one tiny runtime file
&lt;/h2&gt;

&lt;p&gt;Most of the site is just files on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public/
  index.html
  tools/*.html
  assets/js/*.js
  assets/css/*.css
  service-worker.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one small exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public/data/visits.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PHP endpoint reads and writes that file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$dataFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/../data/visits.json'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dataFile&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dataFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'today'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'date'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'start_date'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-05-01'&lt;/span&gt;
    &lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dataFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'REQUEST_METHOD'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'today'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dataFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is fine for a tiny site. It is not pretending to be a serious analytics platform. It just gives me a basic heartbeat without shipping a large third-party analytics script.&lt;/p&gt;

&lt;p&gt;But it also means &lt;code&gt;visits.json&lt;/code&gt; is not source code.&lt;/p&gt;

&lt;p&gt;It is runtime data.&lt;/p&gt;

&lt;p&gt;And my deploy script forgot that distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: full replacement treated runtime data like build output
&lt;/h2&gt;

&lt;p&gt;The original deploy process was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build a package locally.&lt;/li&gt;
&lt;li&gt;Upload it to the server.&lt;/li&gt;
&lt;li&gt;Back up the live site.&lt;/li&gt;
&lt;li&gt;Remove the old public directory.&lt;/li&gt;
&lt;li&gt;Extract the new package.&lt;/li&gt;
&lt;li&gt;Fix ownership and permissions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dangerous part looked conceptually like this:&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/public"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/config"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/deploy"&lt;/span&gt;
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PACKAGE_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www:www &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a common pattern for small static sites. It keeps production from accumulating stale files. It makes deploys predictable. It avoids weird cases where a deleted local file keeps surviving online forever.&lt;/p&gt;

&lt;p&gt;But it also deleted this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$SITE_DIR/public/data/visits.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the next request, the PHP endpoint did exactly what it was written to do: it saw a missing file, recreated it from zero, and then incremented it.&lt;/p&gt;

&lt;p&gt;So production proudly reported:&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="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"today"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code was not broken. The deploy model was incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #1: restore from the highest trustworthy backup
&lt;/h2&gt;

&lt;p&gt;The first step was not changing code. It was recovering the most trustworthy data.&lt;/p&gt;

&lt;p&gt;Because the deploy script created full backups before replacing the site, I could inspect older backup directories and compare their &lt;code&gt;public/data/visits.json&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;The highest trustworthy pre-reset value was:&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;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;175&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"today"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-01"&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;I restored that exact JSON to production, fixed ownership, and verified the API returned the expected value.&lt;/p&gt;

&lt;p&gt;The important detail: do not restore from the newest backup blindly.&lt;/p&gt;

&lt;p&gt;After a reset happens, later backups may contain the already-corrupted value. In my case, some newer backups contained reset-derived counts. The right source was the newest backup from before the reset, not simply the latest backup on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #2: preserve runtime data during full deploys
&lt;/h2&gt;

&lt;p&gt;The actual deploy fix was small.&lt;/p&gt;

&lt;p&gt;After creating the backup, I record the old runtime data path:&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAST_BACKUP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;RUNTIME_DATA_BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/public/data"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I still do the full replacement:&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;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/public"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/config"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/README.md"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/.gitignore"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/deploy"&lt;/span&gt;
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PACKAGE_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But immediately after extraction, I copy the runtime data back:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNTIME_DATA_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/public/data"&lt;/span&gt;
  &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNTIME_DATA_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;/."&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/public/data/"&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www:www &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now full deploys still remove stale build output, but they no longer erase production-owned state.&lt;/p&gt;

&lt;p&gt;That is the rule I should have written down earlier:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Full deploys may replace build artifacts. They must not replace runtime data unless that is an explicit migration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Fix #3: make the deploy script prove it is safe
&lt;/h2&gt;

&lt;p&gt;A deploy script is production code. It deserves at least a syntax check.&lt;/p&gt;

&lt;p&gt;Before trusting the new version, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash &lt;span class="nt"&gt;-n&lt;/span&gt; /root/server-deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then after the next deploy, I checked the file directly:&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;cat&lt;/span&gt; /www/wwwroot/24picture.com/public/data/visits.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And checked the public endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/api/visit.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The post-deploy rule is now simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If total or today unexpectedly returns to 0 or 1, stop.
Do not keep deploying.
Check runtime data before touching anything else.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds obvious after the incident. It was not in my checklist before the incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache lesson from the same release
&lt;/h2&gt;

&lt;p&gt;The same release also touched the homepage and language switching. That created a different kind of deployment risk: cache visibility.&lt;/p&gt;

&lt;p&gt;A user clicking &lt;code&gt;?lang=zh&lt;/code&gt; but still seeing English is not a small i18n bug from their point of view. It looks like the site is ignoring them.&lt;/p&gt;

&lt;p&gt;On a static site with a Service Worker, cache-busting needs to be specific. I ended up treating assets differently based on where they live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shell-level assets need a Service Worker cache version bump.&lt;/li&gt;
&lt;li&gt;Shared JavaScript files need changed query strings in the HTML that references them.&lt;/li&gt;
&lt;li&gt;CSS-only hotfixes can often be isolated to the affected page or stylesheet reference.&lt;/li&gt;
&lt;li&gt;Shared data files need every consuming page to reference the new version if you want the update visible immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/js/i18n.js?v=1.8.14"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/assets/css/main-new.css?v=1.8.15"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/js/changelog-data.js?v=1.8.15"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote a short &lt;a href="https://24picture.com/changelog.html" rel="noopener noreferrer"&gt;public changelog&lt;/a&gt; entry for the release because this kind of tiny infrastructure work is easy to forget. The code change may be one line, but the operational lesson is much bigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  My new tiny-site deploy checklist
&lt;/h2&gt;

&lt;p&gt;Here is the checklist I am using now.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Classify every path before deleting anything
&lt;/h3&gt;

&lt;p&gt;Before a full replacement deploy, split files into categories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build artifacts:
  public/*.html
  public/assets/**
  public/tools/**

runtime data:
  public/data/visits.json
  uploads/
  cache/
  queues/
  generated reports
  anything written by production code

secrets/config:
  .env
  config/*.php
  server-only keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a production process writes it, the deploy script should not casually delete it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Preserve runtime data explicitly
&lt;/h3&gt;

&lt;p&gt;Do not rely on “it is probably in the package” or “the backup exists somewhere.”&lt;/p&gt;

&lt;p&gt;Make the restore step part of the deploy script:&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;RUNTIME_DATA_BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/public/data"&lt;/span&gt;

&lt;span class="c"&gt;# after extracting the new package&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNTIME_DATA_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/public/data"&lt;/span&gt;
  &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNTIME_DATA_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;/."&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/public/data/"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Explicit beats remembered.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Verify the values users can see
&lt;/h3&gt;

&lt;p&gt;Do not only check whether the deploy command exited successfully.&lt;/p&gt;

&lt;p&gt;Check the thing the user will notice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Homepage loads
Language switch renders the expected language
Changelog shows the newest version
Visitor stats did not reset
Critical assets use the new cache-busted URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A deploy that returns &lt;code&gt;DEPLOY_OK&lt;/code&gt; can still ship a bad state.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Keep one small audit script per scary class of bug
&lt;/h3&gt;

&lt;p&gt;I now prefer small, boring verification scripts over trying to remember every edge case manually.&lt;/p&gt;

&lt;p&gt;Examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify homepage i18n keys
scan changelog asset versions
check broken internal links
check missing hreflang tags
scan for mojibake signatures
verify visit stats after deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The best audit scripts are not clever. They are just executable memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Write the postmortem while it is still embarrassing
&lt;/h3&gt;

&lt;p&gt;This is the part I recommend most.&lt;/p&gt;

&lt;p&gt;When the bug still feels annoying, write down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what happened&lt;/li&gt;
&lt;li&gt;why it happened&lt;/li&gt;
&lt;li&gt;what file caused it&lt;/li&gt;
&lt;li&gt;what exact check would have caught it&lt;/li&gt;
&lt;li&gt;what rule changes now&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A week later, you will remember “the stats bug.” You may not remember the exact path, command, backup, and prevention rule.&lt;/p&gt;

&lt;p&gt;Future-you needs the boring details.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small ask
&lt;/h2&gt;

&lt;p&gt;One practical problem with small independent tools is discovery. Search engines trust links, and new projects do not have many.&lt;/p&gt;

&lt;p&gt;If you maintain a list of privacy-friendly tools, browser-based utilities, frontend resources, or “no upload” image tools, and you think this project belongs there, a mention or backlink to &lt;a href="https://24picture.com/tools/webp-to-png.html" rel="noopener noreferrer"&gt;24Picture's zero-upload image tools&lt;/a&gt; would genuinely help.&lt;/p&gt;

&lt;p&gt;No pressure, and please do not link it if it is not useful for your audience. But if it fits, that kind of independent web curation is still one of the best ways small tools get found.&lt;/p&gt;

&lt;p&gt;I am also happy to look at useful directories, resource lists, or indie tool pages in return. Good lists deserve links too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap
&lt;/h2&gt;

&lt;p&gt;The bug was small. One JSON file disappeared.&lt;/p&gt;

&lt;p&gt;But the lesson was bigger: static deploys are only simple if everything is actually static.&lt;/p&gt;

&lt;p&gt;The moment a production process writes a file, even a tiny one, your deploy script has to treat it as state. If it does not, the script is not just deploying your site. It is editing your production data model.&lt;/p&gt;

&lt;p&gt;And sometimes the first sign is a very loud number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>devops</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I shipped 29 browser-only image tools. These 5 boring patterns kept the codebase sane</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Wed, 27 May 2026 03:10:07 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-shipped-29-browser-only-image-tools-these-5-boring-patterns-kept-the-codebase-sane-29e1</link>
      <guid>https://dev.to/dngzihng114379/i-shipped-29-browser-only-image-tools-these-5-boring-patterns-kept-the-codebase-sane-29e1</guid>
      <description>&lt;p&gt;I thought building browser-only image tools would mostly be about Canvas APIs and file formats.&lt;/p&gt;

&lt;p&gt;It wasn’t.&lt;/p&gt;

&lt;p&gt;The hard part started after tool #3, then tool #12, then tool #29: shared logic that wanted to fork, decoder libraries that were too expensive to load everywhere, export bugs that only showed up on transparent images, hash links that broke on real version IDs, and “fixed” assets that users still couldn’t see because cache visibility was treated as an afterthought.&lt;/p&gt;

&lt;p&gt;For the past few weekends I’ve been shipping &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24Picture&lt;/a&gt;, a free browser-local image toolkit. The stack is intentionally boring: static HTML, ES2020 JavaScript, Canvas, and a few route-specific decoders. No backend image processing. No uploads. Just a growing set of small tools that all need to feel consistent, stay fast, and survive hotfixes without turning into a pile of one-off exceptions.&lt;/p&gt;

&lt;p&gt;Here are five patterns that actually held up.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One shared dispatcher beats cloned tool logic
&lt;/h2&gt;

&lt;p&gt;The first few tools tempted me into the usual trap: one page, one script, one slightly different copy of the same workflow. That feels harmless at tool #2. By tool #10, it becomes a maintenance tax. By tool #29, it means every upload rule, MIME check, preview behavior, download naming fix, and UX improvement has to be rediscovered in multiple places.&lt;/p&gt;

&lt;p&gt;The pattern that held up was much less exciting: one shared dispatcher and one route-to-format matrix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROUTE_MATRIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp-to-png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp-to-jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png-to-jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tiff-to-jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/tiff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decodeTiff&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tiff-to-png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/tiff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decodeTiff&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ico-to-png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/x-icon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/vnd.microsoft.icon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decodeIco&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// …29 routes&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tool page sets a route hint like &lt;code&gt;&amp;lt;body data-route="tiff-to-jpg"&amp;gt;&lt;/code&gt;, and the shared script looks up its row at boot. Adding a new converter becomes “one HTML page + one matrix row” instead of “copy three files and hope nothing drifted.”&lt;/p&gt;

&lt;p&gt;The real win isn’t lines saved. It’s that drag-drop, preview, validation, download naming, empty-state copy, and error handling all live in one place. When a UX bug shows up, I fix it once and 29 tools inherit it.&lt;/p&gt;

&lt;p&gt;If the user experience is the same, the code path should probably be shared too.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Lazy-load the expensive decoders, not the whole site
&lt;/h2&gt;

&lt;p&gt;Not every browser image format is created equal. Some tools only need Canvas. Others need heavier decoders for formats like HEIC, TIFF, or ICO. The mistake would have been treating those libraries as “site-wide JavaScript” and making every visitor pay for them on first load.&lt;/p&gt;

&lt;p&gt;Some of the specialized libraries are big enough to matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;When you actually need it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heic2any&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~1.3 MB&lt;/td&gt;
&lt;td&gt;only on HEIC tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UTIF.js&lt;/code&gt; + &lt;code&gt;pako_inflate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;~79 KB&lt;/td&gt;
&lt;td&gt;only on TIFF tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;icojs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~11 KB&lt;/td&gt;
&lt;td&gt;only on the ICO tool&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The better rule was simple: if only a small slice of pages needs a decoder, only those pages should load it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- tools/tiff-to-jpg.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/vendor/pako/pako_inflate.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/vendor/utif/UTIF.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/js/tool-page.js?v=1.8.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared dispatcher only invokes a decoder when its route declares one. The homepage stays lighter, the general tools stay decoder-free, and specialized features stop dragging the entire site down with them.&lt;/p&gt;

&lt;p&gt;Don’t let your homepage pay the bundle tax for edge-case formats.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. JPG export needs a deliberate transparency policy
&lt;/h2&gt;

&lt;p&gt;One of the most boring bugs turned out to be one of the most user-visible: exporting transparent images to JPG. If you don’t make the background policy explicit, the result is whatever the browser gives you — and that often means black where users expected white.&lt;/p&gt;

&lt;p&gt;The fix is tiny, but the rule matters more than the code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson wasn’t just “remember to fill white first.” The real lesson was that format-specific assumptions should never be scattered across multiple tools. If JPG export has a quirk, centralize that logic once and make every &lt;code&gt;*-to-jpg&lt;/code&gt; page inherit it.&lt;/p&gt;

&lt;p&gt;Format quirks become production bugs when they’re duplicated instead of standardized.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A harmless-looking &lt;code&gt;querySelector()&lt;/code&gt; can break production navigation
&lt;/h2&gt;

&lt;p&gt;Some bugs don’t look like bugs until real data hits them. One production example came from a &lt;a href="https://24picture.com/changelog.html" rel="noopener noreferrer"&gt;public changelog&lt;/a&gt; with version IDs like &lt;code&gt;v1.7.2&lt;/code&gt;. The smooth-scroll handler fed the raw &lt;code&gt;href&lt;/code&gt; straight into &lt;code&gt;querySelector()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 🔥 will throw on hashes like #v1.7.2&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href^="#"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollIntoView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;smooth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looked fine until the real IDs contained dots. Then the page started throwing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught SyntaxError: Failed to execute 'querySelector' on 'Document':
'#v1.7.2' is not a valid selector.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CSS, &lt;code&gt;.&lt;/code&gt; is a class delimiter, so &lt;code&gt;#v1.7.2&lt;/code&gt; isn’t interpreted as a literal ID. It’s interpreted as something structurally different — and invalid for this case.&lt;/p&gt;

&lt;p&gt;The safer fix was to stop being clever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I really needed selector semantics, &lt;code&gt;CSS.escape()&lt;/code&gt; would also work. But this bug was a good reminder that production identifiers are rarely as clean as demo values. The more direct DOM API is often the more robust one.&lt;/p&gt;

&lt;p&gt;Real production IDs don’t care whether your selector assumptions are elegant.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Shared asset hotfixes need a visibility strategy, not just cache busting
&lt;/h2&gt;

&lt;p&gt;One of the most frustrating classes of bugs is the kind you already fixed, deployed, and still can’t reliably show to users. On a static site with shared JavaScript, “the file changed” is not the same thing as “the update is visible.”&lt;/p&gt;

&lt;p&gt;The reason is that not every shared asset lives in the same caching layer. Some resources are effectively part of your shell and Service Worker lifecycle. Others are just shared runtime files referenced by many HTML pages. Treating both cases as the same problem leads to half-fixes.&lt;/p&gt;

&lt;p&gt;For shell-level changes, a Service Worker version bump is still the right lever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/service-worker.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1.8.11&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SHELL_CACHE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`app-shell-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RUNTIME_CACHE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`app-runtime-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But for non-precached shared assets, visibility often comes from changing the referenced URL and deploying the HTML that points to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/js/changelog-data.js?v=1.8.12"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction mattered a lot on a multi-language static site. A shared data file can be perfectly updated on origin and still appear “stuck” if many pages continue referencing the old path, or if intermediate caches never see a URL change. The question that finally improved hotfix reliability wasn’t “did I bust cache?” It was: which layer is caching this file, which pages reference it, and what exactly has to change before a user can observe the fix?&lt;/p&gt;

&lt;p&gt;A hotfix is only real when users can actually see it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrap
&lt;/h2&gt;

&lt;p&gt;None of these patterns are especially novel. That’s exactly why they were worth keeping.&lt;/p&gt;

&lt;p&gt;When you ship a lot of small browser tools, the flashy part usually isn’t the hard part. The hard part is making sure the same rules still hold when the codebase grows, when shared assets change, and when one tiny bug suddenly affects twenty pages instead of two.&lt;/p&gt;

&lt;p&gt;That’s what made the boring patterns valuable: they kept the project moving without demanding a rewrite every time a new tool or edge case showed up.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Shipping 28 browser-only image tools: 5 patterns from a quiet weekend project</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sat, 23 May 2026 10:02:52 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/shipping-28-browser-only-image-tools-5-patterns-from-a-quiet-weekend-project-242e</link>
      <guid>https://dev.to/dngzihng114379/shipping-28-browser-only-image-tools-5-patterns-from-a-quiet-weekend-project-242e</guid>
      <description>&lt;p&gt;Five concrete patterns from building a no-upload, browser-local image toolkit — a single dispatcher for many converters, lazy-loaded decoders, transparency-safe JPG export, a subtle querySelector trap, and the cache-busting recipe that finally made hotfixes feel atomic.&lt;/p&gt;

&lt;p&gt;For the past few weekends I've been quietly shipping a free, browser-local image toolkit called &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24Picture&lt;/a&gt;. No uploads, no accounts, no servers — every conversion, resize, crop, watermark, and GIF builder runs on the user's own device.&lt;/p&gt;

&lt;p&gt;The codebase is intentionally boring: plain HTML, ES2020 JavaScript, and Canvas. Nothing fancy. But after shipping 28 tools and ~30 supporting blog posts in five languages, a few patterns have proved themselves over and over — and a few traps have bitten me in ways that produced surprisingly weird bugs.&lt;/p&gt;

&lt;p&gt;Here are five of those patterns. They're worth stealing if you're building anything that ships a lot of small browser tools, processes user files locally, or just wants to keep a healthy Lighthouse score without becoming a build-tooling project.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One shared dispatcher beats N copies of similar tool code
&lt;/h2&gt;

&lt;p&gt;I originally hand-wrote a converter file per pair of formats. After the third one I gave up.&lt;/p&gt;

&lt;p&gt;Now there's a single &lt;code&gt;tool-page.js&lt;/code&gt; that holds a tiny route → format-matrix table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROUTE_MATRIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp-to-png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp-to-jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png-to-jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tiff-to-jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/tiff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decodeTiff&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tiff-to-png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/tiff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decodeTiff&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ico-to-png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/x-icon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/vnd.microsoft.icon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="na"&gt;outputMime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decodeIco&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// …28 entries&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tool page sets &lt;code&gt;&amp;lt;body data-route="tiff-to-jpg"&amp;gt;&lt;/code&gt; and the dispatcher looks up its row at boot. Adding a new tool is one line + one tool HTML page.&lt;/p&gt;

&lt;p&gt;The real win isn't lines saved — it's that the upload zone, preview, MIME validation, batch download UX, drag-drop hints, and download naming convention all live in one place. No duplicated bug fixes. When you find a UX flaw, you fix it 28 times by editing one file.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Lazy-load heavy decoders only on the pages that need them
&lt;/h2&gt;

&lt;p&gt;Some browser image libraries are huge:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;When you actually need it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heic2any&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~1.3 MB&lt;/td&gt;
&lt;td&gt;only on HEIC tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UTIF.js&lt;/code&gt; + &lt;code&gt;pako_inflate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;~79 KB&lt;/td&gt;
&lt;td&gt;only on TIFF tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;icojs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~11 KB&lt;/td&gt;
&lt;td&gt;only on the ICO tool&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If I had loaded all of these globally on every page, the homepage would have paid a 1.5 MB tax for a feature 95% of users will never touch.&lt;/p&gt;

&lt;p&gt;Instead, each tool HTML pulls only its own decoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- tools/tiff-to-jpg.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/vendor/pako/pako_inflate.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/vendor/utif/UTIF.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/js/tool-page.js?v=1.8.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared dispatcher only invokes a decoder when its row declares one. The rest of the site stays decoder-free.&lt;/p&gt;

&lt;p&gt;If you go this route, host the libraries yourself (&lt;code&gt;/assets/vendor/&amp;lt;lib&amp;gt;/…&lt;/code&gt;) rather than relying on a third-party CDN. The whole point of a "no upload, runs locally" tool is undermined the moment you start fanning out to ten random origins on first paint.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Always flatten transparency before exporting JPG
&lt;/h2&gt;

&lt;p&gt;This one is mundane but I caught myself making the mistake twice. JPG has no alpha channel, so if you draw a PNG with transparent areas straight to a canvas and call &lt;code&gt;toBlob('image/jpeg')&lt;/code&gt;, you get &lt;strong&gt;black&lt;/strong&gt;, not white, in most browsers.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Paint white first — transparent source pixels otherwise become black in JPG.&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap it in a single helper and call it from every &lt;code&gt;*-to-jpg&lt;/code&gt; tool. Pick a quality (0.85–0.92 is a safe sweet spot for photos), and stop worrying about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Don't pass &lt;code&gt;href&lt;/code&gt; straight into &lt;code&gt;querySelector&lt;/code&gt; for hash anchors
&lt;/h2&gt;

&lt;p&gt;This bug shipped to production for several days before I noticed it. The site has a &lt;code&gt;/changelog/&lt;/code&gt; page with version IDs like &lt;code&gt;v1.7.2&lt;/code&gt;. The smooth-scroll handler was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 🔥 will throw on click for ANY hash containing a dot&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href^="#"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollIntoView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;smooth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking the sidebar &lt;code&gt;#v1.7.2&lt;/code&gt; link blew up the page with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught SyntaxError: Failed to execute 'querySelector' on 'Document':
'#v1.7.2' is not a valid selector.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CSS, &lt;code&gt;.&lt;/code&gt; is a class delimiter, so &lt;code&gt;#v1.7.2&lt;/code&gt; is parsed as "element with ID &lt;code&gt;v1&lt;/code&gt; that also has classes &lt;code&gt;7&lt;/code&gt; and &lt;code&gt;2&lt;/code&gt;" — structurally invalid.&lt;/p&gt;

&lt;p&gt;Two fixes that always work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Preferred: skip CSS entirely for hash anchors&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Or, if you really need a selector, escape it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I now grep the whole codebase for &lt;code&gt;querySelector(.*getAttribute\(.href&lt;/code&gt; before every release, and the rule is written into our skill docs so it never sneaks back in.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. When you change a shared asset, bump the Service Worker AND add &lt;code&gt;?v=&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The site's Service Worker uses &lt;code&gt;stale-while-revalidate&lt;/code&gt; for static assets — fast for users, but it means an old cached &lt;code&gt;i18n.js&lt;/code&gt; can hang around for a long visit. Worse, edge / origin caches can hold on to the path-without-query independently.&lt;/p&gt;

&lt;p&gt;The cheapest belt-and-suspenders recipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/service-worker.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1.8.3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// bump this on shared asset changes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SHELL_CACHE&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`app-shell-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RUNTIME_CACHE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`app-runtime-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- referencing HTML --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/js/i18n.js?v=1.8.3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern, every time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Edit the shared JS/CSS.&lt;/li&gt;
&lt;li&gt;Bump &lt;code&gt;VERSION&lt;/code&gt; in the service worker.&lt;/li&gt;
&lt;li&gt;Update the &lt;code&gt;?v=&lt;/code&gt; query string on every page that references the file.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The SW bump invalidates &lt;code&gt;RUNTIME_CACHE&lt;/code&gt; on next activate. The &lt;code&gt;?v=&lt;/code&gt; busts any intermediate cache by changing the URL. Either one alone leaves a long tail of stale clients; the two together are what finally made hotfixes feel atomic on a multi-language static site.&lt;/p&gt;

&lt;p&gt;Bonus tip: also wire the SW registration itself to a versioned URL so a stale &lt;code&gt;/service-worker.js&lt;/code&gt; response can't keep an old SW alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pwa-install.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SW_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.8.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/service-worker.js?v=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SW_VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Wrap
&lt;/h2&gt;

&lt;p&gt;None of these patterns are clever. They're just the boring tools that have actually kept the project moving on weekend after weekend, while shipping 28 tools across 5 languages without a backend.&lt;/p&gt;

&lt;p&gt;If you want to see them in production, &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;the whole toolkit&lt;/a&gt; is browser-local and free, and the &lt;a href="https://24picture.com/changelog.html" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; literally documents the version where each of these patterns was learned the hard way (including v1.8.2, the "querySelector hash" release).&lt;/p&gt;

&lt;p&gt;Happy shipping — and may all your weekend projects compile in the browser. 🛠️&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Canvas 2D API is Underrated — 5 Real Tools I Built With It</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Fri, 22 May 2026 16:31:33 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/canvas-2d-api-is-underrated-5-real-tools-i-built-with-it-52m4</link>
      <guid>https://dev.to/dngzihng114379/canvas-2d-api-is-underrated-5-real-tools-i-built-with-it-52m4</guid>
      <description>&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%2Fqbc1zyilrdo7e0awxpfw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbc1zyilrdo7e0awxpfw.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
Most web developers reach for heavy libraries the moment they need to do anything visual. Need a chart? Chart.js. Image editing? Fabric.js. PDF export? html2canvas.&lt;/p&gt;

&lt;p&gt;I've been building &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a suite of 62 free browser-based tools — and I kept running into the same realization: &lt;strong&gt;the native Canvas 2D API can do way more than people think.&lt;/strong&gt; No npm install, no bundle size, no compatibility issues. Just &lt;code&gt;getContext('2d')&lt;/code&gt; and go.&lt;/p&gt;

&lt;p&gt;Here are five real, production tools I built using nothing but vanilla Canvas 2D.&lt;/p&gt;


&lt;h2&gt;
  
  
  1. Signature Pad — Draw, Trim, Export Transparent PNG
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://toolknit.com/tools/signature-maker.html" rel="noopener noreferrer"&gt;Signature Maker&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most recent one. Users draw their signature with mouse/touch/stylus, and download it as a &lt;strong&gt;transparent PNG&lt;/strong&gt; — ready to paste into documents.&lt;/p&gt;
&lt;h3&gt;
  
  
  The core drawing loop
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mousedown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;drawing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;saveState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// push to undo stack&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getPos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;lastX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;lastY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mousemove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;drawing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getPos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;penColor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;penSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;round&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineJoin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;round&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;lastX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;lastY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Nothing revolutionary — but the magic is in what happens &lt;em&gt;after&lt;/em&gt; drawing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Auto-trimming transparent pixels
&lt;/h3&gt;

&lt;p&gt;When the user downloads, I scan every pixel to find the bounding box of the actual signature, then crop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trimCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// alpha &amp;gt; 0&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 10px padding each side&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a tight, transparent PNG — no white box, no wasted space. Works perfectly when overlaid on PDFs or documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  HiDPI support
&lt;/h3&gt;

&lt;p&gt;One gotcha: on Retina/HiDPI screens, the canvas looks blurry if you don't account for &lt;code&gt;devicePixelRatio&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;devicePixelRatio&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the internal resolution match the physical pixels. Lines look crisp on every screen.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. AI Background Remover — Mask Painting with Canvas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://toolknit.com/tools/background-remover.html" rel="noopener noreferrer"&gt;Background Remover&lt;/a&gt; (&lt;a href="https://github.com/2645149786-dotcom/toolknit/tree/main/open-source/background-remover-standalone" rel="noopener noreferrer"&gt;open-sourced&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;An ONNX neural network removes the background automatically (via WebAssembly). But the AI isn't perfect — especially around hair, transparent objects, or complex edges.&lt;/p&gt;

&lt;p&gt;So I added a &lt;strong&gt;manual refinement mode&lt;/strong&gt;: paint on a mask canvas with a brush (restore) or eraser (remove).&lt;/p&gt;

&lt;h3&gt;
  
  
  Dual-canvas compositing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 1: Original image (never modified)
Layer 2: Mask canvas (white = keep, black = remove)
Layer 3: Preview canvas (composited result)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The brush paints directly on the mask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;paintOnMask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;currentTool&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brush&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source-over&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;destination-out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;brushSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;round&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the preview composites original × mask in real-time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderPreview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;previewCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Draw checkered background (transparency indicator)&lt;/span&gt;
  &lt;span class="nf"&gt;drawCheckerboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previewCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Apply mask: only show original where mask is white&lt;/span&gt;
  &lt;span class="nx"&gt;previewCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source-over&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;previewCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;previewCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;destination-in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;previewCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maskCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;destination-in&lt;/code&gt; is the key — it keeps original pixels only where the mask has alpha. Pure Canvas compositing, zero libraries.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Pixel Art Converter — Quantization &amp;amp; Palette Mapping
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://toolknit.com/tools/pixel-art-converter.html" rel="noopener noreferrer"&gt;Pixel Art Converter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Upload a photo, get retro pixel art. The algorithm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Downscale&lt;/strong&gt; to target pixel size (e.g., 64×64)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Color quantize&lt;/strong&gt; to a limited palette (8, 16, 32 colors)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upscale&lt;/strong&gt; with nearest-neighbor (no anti-aliasing)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Nearest-neighbor upscaling
&lt;/h3&gt;

&lt;p&gt;Browsers default to bilinear interpolation when scaling canvas images. For pixel art, you need hard edges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageSmoothingEnabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;smallCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line — &lt;code&gt;imageSmoothingEnabled = false&lt;/code&gt; — turns a blurry mess into crisp pixel art. This single property makes the Canvas API viable for an entire category of retro image tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  Color palette mapping
&lt;/h3&gt;

&lt;p&gt;For each pixel, I find the closest color in the target palette using Euclidean distance in RGB space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;nearestColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;minDist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;dg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dg&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;minDist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;minDist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Process every pixel with &lt;code&gt;getImageData&lt;/code&gt; / &lt;code&gt;putImageData&lt;/code&gt;, and the entire conversion runs in a single frame on most devices.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Daily Planner — PDF/PNG Export via Pure Canvas Drawing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://toolknit.com/tools/daily-planner.html" rel="noopener noreferrer"&gt;Daily Planner&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Users fill out a daily schedule in the browser. They can export it as a &lt;strong&gt;pixel-perfect PNG or PDF&lt;/strong&gt;. My first attempt used &lt;code&gt;html2canvas&lt;/code&gt; to screenshot the DOM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why html2canvas failed
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;html2canvas&lt;/code&gt; re-implements CSS rendering in JavaScript. It doesn't use the browser's actual layout engine. The result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;flexbox&lt;/code&gt; alignment was off by 2-3px&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;margin: auto&lt;/code&gt; centering didn't work&lt;/li&gt;
&lt;li&gt;Font rendering differed subtly&lt;/li&gt;
&lt;li&gt;Every CSS fix I made for html2canvas broke the live preview&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After hours of fighting, I threw it away and drew everything manually with Canvas 2D.&lt;/p&gt;

&lt;h3&gt;
  
  
  Direct Canvas rendering
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderToCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Wait for fonts&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Background&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0a0a0a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Title — perfectly centered&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;700 36px "Space Grotesk"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textAlign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Time slots — precise positioning&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slot&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;600 16px "Space Grotesk"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(255,255,255,0.5)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textAlign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Zero rendering discrepancies.&lt;/strong&gt; The PNG/PDF output matches exactly what the user sees — because I control every pixel. The lesson: &lt;strong&gt;if you need pixel-perfect export, skip DOM-to-canvas libraries and draw directly.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  5. AI to PNG — Rendering Vector Files via Canvas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://toolknit.com/tools/ai-to-png.html" rel="noopener noreferrer"&gt;AI to PNG&lt;/a&gt; (&lt;a href="https://github.com/2645149786-dotcom/toolknit/tree/main/open-source/ai-to-png-standalone" rel="noopener noreferrer"&gt;open-sourced&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Adobe Illustrator &lt;code&gt;.ai&lt;/code&gt; files saved with PDF compatibility can be parsed by &lt;code&gt;pdf.js&lt;/code&gt;. Each page/artboard gets rendered to a Canvas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pdfDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageNum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;canvasContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Now canvas contains the rendered artboard&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Download as PNG or add to ZIP&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Canvas is the final rendering target — pdf.js draws vector paths, text, and embedded images onto it. The user gets a high-resolution PNG without ever installing Adobe software.&lt;/p&gt;

&lt;p&gt;For batch exports (multiple artboards), each canvas gets converted to a blob and packed into a ZIP using JSZip — all in the browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Canvas 2D Over Libraries?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Canvas 2D&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bundle size&lt;/td&gt;
&lt;td&gt;50-500 KB&lt;/td&gt;
&lt;td&gt;0 KB (native)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser support&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;Universal (IE9+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rendering accuracy&lt;/td&gt;
&lt;td&gt;Approximation&lt;/td&gt;
&lt;td&gt;Exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve&lt;/td&gt;
&lt;td&gt;API-specific&lt;/td&gt;
&lt;td&gt;One API for everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Touch/mobile&lt;/td&gt;
&lt;td&gt;Often buggy&lt;/td&gt;
&lt;td&gt;Native support&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'm not saying never use libraries. But before you &lt;code&gt;npm install&lt;/code&gt;, ask: &lt;strong&gt;can Canvas 2D do this natively?&lt;/strong&gt; You'll be surprised how often the answer is yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;Every tool above follows the same architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Input → Canvas Processing → Blob Export → Download/Clipboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No server. No upload. No framework. Just the browser doing what it was built for.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;All 62 tools are free at &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;toolknit.com&lt;/a&gt;.&lt;/strong&gt; Background Remover and AI to PNG are &lt;a href="https://github.com/2645149786-dotcom/toolknit" rel="noopener noreferrer"&gt;open-sourced on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've built something cool with Canvas 2D, I'd love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Open-Sourced a Browser-Based AI Background Remover — Here's the Full Architecture</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Wed, 20 May 2026 03:30:59 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-open-sourced-a-browser-based-ai-background-remover-heres-the-full-architecture-1olb</link>
      <guid>https://dev.to/dngzihng114379/i-open-sourced-a-browser-based-ai-background-remover-heres-the-full-architecture-1olb</guid>
      <description>&lt;p&gt;Most background removal tools work like this: upload your photo to a server, wait for an AI model to process it, download the result. Your image sits on someone else's infrastructure. You hope they delete it.&lt;/p&gt;

&lt;p&gt;I built one that works differently. The AI model runs &lt;strong&gt;in your browser tab&lt;/strong&gt;. Your image never leaves your device. And I just &lt;a href="https://github.com/2645149786-dotcom/toolknit/tree/main/open-source/background-remover-standalone" rel="noopener noreferrer"&gt;open-sourced the core logic&lt;/a&gt; — two files, zero dependencies beyond a CDN import.&lt;/p&gt;

&lt;p&gt;Here's how it works under the hood.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pipeline
&lt;/h2&gt;

&lt;p&gt;The full flow from "user drops an image" to "transparent PNG download" goes through five stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload → ONNX Model Load → WebAssembly Inference → Mask Generation → Canvas Compositing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage runs entirely client-side. Let me walk through them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 1: Loading the AI Model in the Browser
&lt;/h2&gt;

&lt;p&gt;The backbone is &lt;a href="https://img.ly/blog/background-removal-js/" rel="noopener noreferrer"&gt;&lt;code&gt;@imgly/background-removal&lt;/code&gt;&lt;/a&gt;, an open-source library that bundles an ONNX segmentation model with ONNX Runtime Web (WebAssembly backend).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LIB_CDN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.5.5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadLibrary&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LIB_CDN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/+esm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;removeBackgroundFn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;removeBackground&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first call downloads ~40MB of model weights. That sounds heavy, but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The browser caches it automatically&lt;/li&gt;
&lt;li&gt;Subsequent uses load instantly from cache&lt;/li&gt;
&lt;li&gt;No server round-trip on any future use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same trade-off FFmpeg.wasm makes — big initial download, but then your browser becomes a local processing powerhouse.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 2: Running AI Inference Locally
&lt;/h2&gt;

&lt;p&gt;Once the model is loaded, inference is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;removeBackgroundFn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageBlob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Update loading UI&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's happening behind the scenes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The library resizes your image to the model's input dimensions&lt;/li&gt;
&lt;li&gt;Pixel data is converted to a tensor&lt;/li&gt;
&lt;li&gt;ONNX Runtime Web runs the segmentation model via WebAssembly&lt;/li&gt;
&lt;li&gt;The output tensor (a per-pixel foreground probability map) is converted back to an image with transparent background&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;medium&lt;/code&gt; model balances quality and speed. On a decent laptop, inference takes 2-5 seconds for a typical photo. On a phone, maybe 8-15 seconds. Acceptable for a free, private tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 3: Building the Editable Mask
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. The AI output isn't final — it's a starting point. I extract the alpha channel from the AI result and build an editable grayscale mask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildMaskFromResult&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Draw AI result to a temporary canvas&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultCanvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;resultCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;resultCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resultCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;rCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resultImg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Extract alpha channel → grayscale mask&lt;/span&gt;
  &lt;span class="c1"&gt;// White = foreground (keep), Black = background (remove)&lt;/span&gt;
  &lt;span class="nx"&gt;maskCanvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;maskCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;maskCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maskCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maskData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;resultData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resultData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;maskData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// R&lt;/span&gt;
    &lt;span class="nx"&gt;maskData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// G&lt;/span&gt;
    &lt;span class="nx"&gt;maskData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// B&lt;/span&gt;
    &lt;span class="nx"&gt;maskData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// A (mask itself is always opaque)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maskData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why a separate mask canvas?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because users need to fix the AI's mistakes. Hair edges, transparent objects, similar-colored backgrounds — no AI gets these perfect 100% of the time. The mask canvas becomes a paintable surface.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 4: Manual Refinement with Brush &amp;amp; Eraser
&lt;/h2&gt;

&lt;p&gt;This is the feature that separates a toy demo from a usable tool. Users can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Brush&lt;/strong&gt; (paint white on mask) → restore foreground areas the AI removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eraser&lt;/strong&gt; (paint black on mask) → remove background areas the AI missed
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;paintOnMask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;maskCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;maskCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;brushSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brushSizeEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;softness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brushSoftEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;round&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;brushSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Softness = CSS filter blur on the mask canvas context&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;softness&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`blur(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brushSize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;softness&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;px)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentTool&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brush&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lighter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source-over&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key details:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coordinate mapping&lt;/strong&gt;: The edit canvas is CSS-scaled to fit the viewport, but the mask operates at full image resolution. Every mouse position gets mapped from display coordinates to mask coordinates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge softness&lt;/strong&gt;: Uses Canvas 2D &lt;code&gt;filter: blur()&lt;/code&gt; on the stroke — this creates feathered edges instead of hard cuts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undo stack&lt;/strong&gt;: Each mousedown saves a full &lt;code&gt;ImageData&lt;/code&gt; snapshot of the mask. Up to 20 undo levels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The brush cursor is a &lt;code&gt;position: fixed&lt;/code&gt; div that follows the mouse, sized to match the display-scaled brush diameter. The actual canvas cursor is set to &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 5: Compositing the Final Output
&lt;/h2&gt;

&lt;p&gt;To generate the downloadable PNG, the mask is applied to the original image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyMaskToOriginal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;origCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maskCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;origData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;outData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;origData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;       &lt;span class="c1"&gt;// R — original&lt;/span&gt;
    &lt;span class="nx"&gt;outData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;origData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// G — original&lt;/span&gt;
    &lt;span class="nx"&gt;outData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;origData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// B — original&lt;/span&gt;
    &lt;span class="nx"&gt;outData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;       &lt;span class="c1"&gt;// A — from mask R channel&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;oCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;outCanvas&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mask's R channel (which equals G and B since it's grayscale) becomes the alpha channel of the output. White mask pixels → fully opaque. Black → fully transparent. Gray → semi-transparent (useful for hair and soft edges).&lt;/p&gt;




&lt;h2&gt;
  
  
  The Refine Mode Overlay
&lt;/h2&gt;

&lt;p&gt;In refine mode, users see the original image with a semi-transparent red overlay on removed areas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderMaskOverlay&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;editCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maskCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overlayData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maskVal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maskVal&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Removed area → semi-transparent red&lt;/span&gt;
      &lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// R&lt;/span&gt;
      &lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// G&lt;/span&gt;
      &lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// B&lt;/span&gt;
      &lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// A&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Kept area → fully transparent (show original underneath)&lt;/span&gt;
      &lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;editCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overlayData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives immediate visual feedback — you can see exactly what the AI removed and paint corrections in real time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance Considerations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory&lt;/strong&gt;: Three full-resolution canvases live in memory (original, mask, output). For a 4000×3000 photo, that's ~144MB of pixel data. Mobile devices with &amp;lt;4GB RAM may struggle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time rendering&lt;/strong&gt;: Every brush stroke triggers &lt;code&gt;renderPreview()&lt;/code&gt; via &lt;code&gt;requestAnimationFrame&lt;/code&gt;. This redraws the preview canvas + overlay from the mask. On large images, there's a noticeable lag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Touch support&lt;/strong&gt;: Full touch event handling with &lt;code&gt;passive: false&lt;/code&gt; to prevent scroll interference.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I Stripped for the Open-Source Version
&lt;/h2&gt;

&lt;p&gt;The production version on &lt;a href="https://toolknit.com/tools/background-remover.html" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Daily usage limits (fair-use throttling)&lt;/li&gt;
&lt;li&gt;Analytics tracking&lt;/li&gt;
&lt;li&gt;Self-hosted model weights (faster loading from our CDN)&lt;/li&gt;
&lt;li&gt;Sound effects on completion&lt;/li&gt;
&lt;li&gt;Site navigation and SEO shell&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/2645149786-dotcom/toolknit/tree/main/open-source/background-remover-standalone" rel="noopener noreferrer"&gt;open-source version&lt;/a&gt; strips all of that down to two files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.html&lt;/code&gt; — standalone UI (~250 lines)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.js&lt;/code&gt; — core logic (~380 lines)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can clone it, run &lt;code&gt;npx serve .&lt;/code&gt;, and have a working background remover in 30 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Some ideas for anyone who wants to fork and extend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Background replacement&lt;/strong&gt; — solid color or custom image behind the subject&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch processing&lt;/strong&gt; — drop multiple images, process all sequentially&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebGPU acceleration&lt;/strong&gt; — ONNX Runtime Web supports WebGPU; inference could be 3-5x faster&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge feathering controls&lt;/strong&gt; — post-process the mask with adjustable blur radius&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before/after slider&lt;/strong&gt; — drag to compare original and result&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live tool&lt;/strong&gt;: &lt;a href="https://toolknit.com/tools/background-remover.html" rel="noopener noreferrer"&gt;toolknit.com/tools/background-remover.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source&lt;/strong&gt;: &lt;a href="https://github.com/2645149786-dotcom/toolknit/tree/main/open-source/background-remover-standalone" rel="noopener noreferrer"&gt;github.com/2645149786-dotcom/toolknit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All 61 tools&lt;/strong&gt;: &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;toolknit.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've ever needed to remove a background without uploading your photo to a random website — this is it. Clone it, use it, break it, improve it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://github.com/2645149786-dotcom" rel="noopener noreferrer"&gt;Zihang Dong&lt;/a&gt;. Building browser-first tools at &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Built a Free Browser-Based Image Tools Platform</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Tue, 19 May 2026 07:12:30 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-built-a-free-browser-based-image-tools-platform-3i6k</link>
      <guid>https://dev.to/dngzihng114379/i-built-a-free-browser-based-image-tools-platform-3i6k</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Privacy-First Online Image Tools Platform That Runs 100% in the Browser
&lt;/h1&gt;

&lt;p&gt;Most online image tools solve one problem and create three new ones.&lt;/p&gt;

&lt;p&gt;You want to compress an image, resize a file, convert PNG to WebP, or add a watermark — and suddenly you're dealing with uploads, account walls, aggressive ads, or “free” tools that stop being useful after one click.&lt;/p&gt;

&lt;p&gt;I wanted something simpler.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://24picture.com/" rel="noopener noreferrer"&gt;24Picture&lt;/a&gt;: a privacy-first online image tools platform focused on speed, simplicity, and browser-based processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;A lot of people don’t need Photoshop.&lt;/p&gt;

&lt;p&gt;They just need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;convert image formats&lt;/li&gt;
&lt;li&gt;compress large files&lt;/li&gt;
&lt;li&gt;resize assets for social media&lt;/li&gt;
&lt;li&gt;crop screenshots&lt;/li&gt;
&lt;li&gt;add watermarks&lt;/li&gt;
&lt;li&gt;create memes or GIFs&lt;/li&gt;
&lt;li&gt;edit images quickly without installing software&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds simple, but the existing experience is often frustrating.&lt;/p&gt;

&lt;p&gt;So I decided to build a toolset that feels lightweight and practical.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes it different
&lt;/h2&gt;

&lt;p&gt;The core idea is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do as much as possible directly in the browser.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That means users can process images without sending files to a remote server for every basic task.&lt;/p&gt;

&lt;p&gt;This has a few clear advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;better privacy&lt;/li&gt;
&lt;li&gt;less friction&lt;/li&gt;
&lt;li&gt;faster iteration&lt;/li&gt;
&lt;li&gt;no account requirement&lt;/li&gt;
&lt;li&gt;no software installation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Current features
&lt;/h2&gt;

&lt;p&gt;24Picture currently includes tools for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JPG to PNG&lt;/li&gt;
&lt;li&gt;PNG to WebP&lt;/li&gt;
&lt;li&gt;HEIC to JPG&lt;/li&gt;
&lt;li&gt;image compression&lt;/li&gt;
&lt;li&gt;image resizing&lt;/li&gt;
&lt;li&gt;image cropping&lt;/li&gt;
&lt;li&gt;adding watermarks&lt;/li&gt;
&lt;li&gt;Base64 conversion&lt;/li&gt;
&lt;li&gt;batch conversion&lt;/li&gt;
&lt;li&gt;SVG optimization&lt;/li&gt;
&lt;li&gt;gradient generation&lt;/li&gt;
&lt;li&gt;meme creation&lt;/li&gt;
&lt;li&gt;GIF creation&lt;/li&gt;
&lt;li&gt;GIF to MP4 conversion&lt;/li&gt;
&lt;li&gt;browser-based image editing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can explore the full platform here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://24picture.com/" rel="noopener noreferrer"&gt;https://24picture.com/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Built for normal users, not just designers
&lt;/h2&gt;

&lt;p&gt;One thing I’ve learned while building this is that many users are not looking for a “creative suite.”&lt;/p&gt;

&lt;p&gt;They’re looking for relief.&lt;/p&gt;

&lt;p&gt;They want to finish a task and move on.&lt;/p&gt;

&lt;p&gt;A seller needs product images resized.&lt;br&gt;
A creator needs a thumbnail compressed.&lt;br&gt;
A student needs a PNG with transparency.&lt;br&gt;
A marketer needs a quick watermark.&lt;br&gt;
A developer needs an image converted to Base64.&lt;/p&gt;

&lt;p&gt;The goal is not to impress them with complexity.&lt;br&gt;
The goal is to help them solve the problem in 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical direction
&lt;/h2&gt;

&lt;p&gt;The project is intentionally lightweight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;static HTML/CSS/JS&lt;/li&gt;
&lt;li&gt;browser-side processing where possible&lt;/li&gt;
&lt;li&gt;multilingual support&lt;/li&gt;
&lt;li&gt;PWA support&lt;/li&gt;
&lt;li&gt;SEO-focused content structure&lt;/li&gt;
&lt;li&gt;no unnecessary build complexity for the public-facing experience&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’ve also been investing in better crawlability, structured metadata, multilingual coverage, sitemap automation, and AI-readable site documentation so the platform becomes easier to discover and understand over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy matters
&lt;/h2&gt;

&lt;p&gt;This was non-negotiable for me.&lt;/p&gt;

&lt;p&gt;A lot of users handle private screenshots, personal photos, client files, or business assets. They should not have to wonder where those files go.&lt;/p&gt;

&lt;p&gt;That’s why privacy-first processing is one of the key principles behind the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;If you work with images regularly and want something fast, simple, and browser-friendly, take a look:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://24picture.com/" rel="noopener noreferrer"&gt;24Picture — Free Online Image Tools&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’m still improving it continuously, adding features, refining UX, fixing edge cases, and expanding content for different languages and use cases.&lt;/p&gt;

&lt;p&gt;If you’ve built something similar — or if you have strong opinions about browser-based media tools — I’d love to hear how you think about the tradeoff between simplicity, privacy, and power.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>javascript</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building a 3D Coin Flip with Pure CSS Animations &amp; Vanilla JS (Open Source)</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Mon, 18 May 2026 16:27:02 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/building-a-3d-coin-flip-with-pure-css-animations-vanilla-js-open-source-2mdh</link>
      <guid>https://dev.to/dngzihng114379/building-a-3d-coin-flip-with-pure-css-animations-vanilla-js-open-source-2mdh</guid>
      <description>&lt;p&gt;How I built a realistic coin flip tool with 3D CSS transforms, parabolic arc physics, and streak tracking — zero dependencies, 150 lines of JS.&lt;br&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%2F9spu30hsfk3znj16ta0e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9spu30hsfk3znj16ta0e.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I needed a coin flip tool for &lt;a href="https://toolknit.com/tools/coin-flip.html" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; that felt &lt;em&gt;physical&lt;/em&gt; — not just a random number with a boring text swap. The goal: a 3D coin that launches into the air, spins realistically, lands with a bounce, and tracks your statistics. All running 100% in the browser with zero dependencies.&lt;/p&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;


&lt;h2&gt;
  
  
  Architecture: 3 Layers of Animation
&lt;/h2&gt;

&lt;p&gt;The trick to realistic coin physics is separating concerns into &lt;strong&gt;three independent CSS animations&lt;/strong&gt; that play simultaneously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────┐
│  .coin-wrapper  → Y trajectory  │  (parabolic arc up/down)
│    └── .coin    → 3D spin       │  (rotateY for flip)
│  .coin-shadow   → ground shadow │  (scale pulse)
└─────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer handles one axis of movement. Combined, they create convincing physics without any JavaScript animation libraries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: The Parabolic Arc (Y-axis)
&lt;/h2&gt;

&lt;p&gt;The wrapper handles the vertical trajectory — launch up, hang at apex, fall back down with a small bounce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;coinToss&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* crouch before launch */&lt;/span&gt;
    &lt;span class="err"&gt;35&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-200px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* rising */&lt;/span&gt;
    &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-220px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* apex — slight hang */&lt;/span&gt;
    &lt;span class="err"&gt;65&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-180px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* falling */&lt;/span&gt;
    &lt;span class="err"&gt;88&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-10px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c"&gt;/* impact */&lt;/span&gt;
    &lt;span class="err"&gt;93&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;       &lt;span class="c"&gt;/* bounce 1 */&lt;/span&gt;
    &lt;span class="err"&gt;96&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-3px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* bounce 2 */&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;         &lt;span class="c"&gt;/* settle */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key insight: The &lt;code&gt;8%&lt;/code&gt; crouch before launch sells the "weight" of the coin. The micro-bounces at 88–100% prevent it from feeling like it stops dead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 2: The 3D Spin
&lt;/h2&gt;

&lt;p&gt;This is where the magic happens. CSS &lt;code&gt;rotateY&lt;/code&gt; with &lt;code&gt;transform-style: preserve-3d&lt;/code&gt; gives us true 3D flipping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Heads: lands face-up (even half-turns = 3240° = 9 full rotations) */&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;spinHeads&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;35&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1080deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;65&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2160deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-10deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;88&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2800deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3240deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* 3240 / 360 = 9 */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Tails: lands face-down (odd half-turns = 3060° = 8.5 rotations) */&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;spinTails&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;35&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1080deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;65&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2160deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-10deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;88&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2800deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3060deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* 3060 / 360 = 8.5 */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Heads lands on an &lt;em&gt;even&lt;/em&gt; number of half-turns (front face visible)&lt;/li&gt;
&lt;li&gt;Tails lands on an &lt;em&gt;odd&lt;/em&gt; number of half-turns (back face visible, thanks to &lt;code&gt;backface-visibility: hidden&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The subtle &lt;code&gt;rotateX&lt;/code&gt; wobble at 35%/65% simulates the coin not spinning on a perfectly flat axis — just like real physics&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Layer 3: Ground Shadow
&lt;/h2&gt;

&lt;p&gt;A radial-gradient ellipse that shrinks when the coin is airborne and expands on landing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;shadowPulse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;10&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;30&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c"&gt;/* coin at height */&lt;/span&gt;
    &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* apex */&lt;/span&gt;
    &lt;span class="err"&gt;70&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c"&gt;/* falling */&lt;/span&gt;
    &lt;span class="err"&gt;85&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;92&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;130px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* impact overshoot */&lt;/span&gt;
    &lt;span class="err"&gt;96&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;110px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* settle */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shadow grounds the coin in 3D space. Without it, the coin feels like it's floating.&lt;/p&gt;




&lt;h2&gt;
  
  
  The JavaScript: 150 Lines, Zero Dependencies
&lt;/h2&gt;

&lt;p&gt;The entire logic is a self-contained IIFE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ANIM_DURATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1450&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// matches CSS 1.4s + buffer&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFlipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flipCoin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isFlipping&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;isFlipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Fair 50/50 random&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Reset animations (remove class → force reflow → add class)&lt;/span&gt;
        &lt;span class="nx"&gt;coin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-tails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;coinWrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tossing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;coin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// force reflow — critical!&lt;/span&gt;

        &lt;span class="c1"&gt;// Trigger all 3 animation layers simultaneously&lt;/span&gt;
        &lt;span class="nx"&gt;coinWrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tossing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;coinShadow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;airborne&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;coin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-tails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Update state after animation completes&lt;/span&gt;
        &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;heads&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tails&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="c1"&gt;// Streak tracking&lt;/span&gt;
            &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;lastResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;updateStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nx"&gt;isFlipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ANIM_DURATION&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Keyboard shortcut: Space to flip&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Space&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nf"&gt;flipCoin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key decisions:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;void coin.offsetWidth&lt;/code&gt;&lt;/strong&gt; — Forces a browser reflow between removing and re-adding animation classes. Without this, the browser optimizes away the class change and the animation won't replay.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;isFlipping&lt;/code&gt; guard&lt;/strong&gt; — Prevents spamming the button during animation. CSS animations can stack weirdly if retriggered mid-flight.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Math.random() &amp;lt; 0.5&lt;/code&gt;&lt;/strong&gt; — Simple and fair. The browser's PRNG is seeded from OS entropy, making it perfectly adequate for a coin flip (this isn't cryptography).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Streak tracking&lt;/strong&gt; — A single variable that resets on result change. Elegant and zero-allocation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Coin Face Structure (HTML)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-shadow"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"coin-shadow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-wrapper"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"coin-wrapper"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"coin"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-face coin-heads"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-shine"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                H
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-face coin-tails"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-shine"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                T
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.coin-tails&lt;/code&gt; face has &lt;code&gt;transform: rotateY(180deg)&lt;/code&gt; so it's initially hidden by &lt;code&gt;backface-visibility: hidden&lt;/code&gt;. The shine overlay is a CSS gradient that gives the coin a metallic reflection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stats &amp;amp; Probability Bar
&lt;/h2&gt;

&lt;p&gt;A dead-simple probability visualization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateStats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tails&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;headsBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tailsBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s inside a flex container with &lt;code&gt;transition: width 0.5s&lt;/code&gt; — the bar animates smoothly as the probability shifts with each flip. Over 100+ flips, you watch it converge to 50/50 (law of large numbers in real time).&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Live
&lt;/h2&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://toolknit.com/tools/coin-flip.html" rel="noopener noreferrer"&gt;ToolKnit Coin Flip&lt;/a&gt;&lt;/strong&gt; — free, no signup, runs entirely in your browser.&lt;/p&gt;

&lt;p&gt;The full source is visible in-browser (View Source) since it's all client-side HTML/CSS/JS. Feel free to learn from it, fork the approach, or adapt it for your own projects.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web Animations API&lt;/strong&gt; instead of CSS keyframes — more control over playback speed, easing, and cancellation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas/WebGL&lt;/strong&gt; for truly custom coin textures (country flags, custom images)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haptic feedback&lt;/strong&gt; on mobile via &lt;code&gt;navigator.vibrate()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But for a zero-dependency, 150-line solution that works on every browser? Pure CSS animations are hard to beat.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built as part of &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a free, open-source collection of 60+ browser-based tools. No uploads, no accounts, works offline.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a 3D Coin Flip with Pure CSS Animations &amp; Vanilla JS (Open Source)</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Mon, 18 May 2026 16:27:02 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/building-a-3d-coin-flip-with-pure-css-animations-vanilla-js-open-source-oi</link>
      <guid>https://dev.to/dngzihng114379/building-a-3d-coin-flip-with-pure-css-animations-vanilla-js-open-source-oi</guid>
      <description>&lt;p&gt;How I built a realistic coin flip tool with 3D CSS transforms, parabolic arc physics, and streak tracking — zero dependencies, 150 lines of JS.&lt;br&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%2F9spu30hsfk3znj16ta0e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9spu30hsfk3znj16ta0e.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I needed a coin flip tool for &lt;a href="https://toolknit.com/tools/coin-flip.html" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; that felt &lt;em&gt;physical&lt;/em&gt; — not just a random number with a boring text swap. The goal: a 3D coin that launches into the air, spins realistically, lands with a bounce, and tracks your statistics. All running 100% in the browser with zero dependencies.&lt;/p&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;


&lt;h2&gt;
  
  
  Architecture: 3 Layers of Animation
&lt;/h2&gt;

&lt;p&gt;The trick to realistic coin physics is separating concerns into &lt;strong&gt;three independent CSS animations&lt;/strong&gt; that play simultaneously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────┐
│  .coin-wrapper  → Y trajectory  │  (parabolic arc up/down)
│    └── .coin    → 3D spin       │  (rotateY for flip)
│  .coin-shadow   → ground shadow │  (scale pulse)
└─────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer handles one axis of movement. Combined, they create convincing physics without any JavaScript animation libraries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: The Parabolic Arc (Y-axis)
&lt;/h2&gt;

&lt;p&gt;The wrapper handles the vertical trajectory — launch up, hang at apex, fall back down with a small bounce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;coinToss&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* crouch before launch */&lt;/span&gt;
    &lt;span class="err"&gt;35&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-200px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* rising */&lt;/span&gt;
    &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-220px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* apex — slight hang */&lt;/span&gt;
    &lt;span class="err"&gt;65&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-180px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* falling */&lt;/span&gt;
    &lt;span class="err"&gt;88&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-10px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c"&gt;/* impact */&lt;/span&gt;
    &lt;span class="err"&gt;93&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;       &lt;span class="c"&gt;/* bounce 1 */&lt;/span&gt;
    &lt;span class="err"&gt;96&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-3px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* bounce 2 */&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;         &lt;span class="c"&gt;/* settle */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key insight: The &lt;code&gt;8%&lt;/code&gt; crouch before launch sells the "weight" of the coin. The micro-bounces at 88–100% prevent it from feeling like it stops dead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 2: The 3D Spin
&lt;/h2&gt;

&lt;p&gt;This is where the magic happens. CSS &lt;code&gt;rotateY&lt;/code&gt; with &lt;code&gt;transform-style: preserve-3d&lt;/code&gt; gives us true 3D flipping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Heads: lands face-up (even half-turns = 3240° = 9 full rotations) */&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;spinHeads&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;35&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1080deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;65&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2160deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-10deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;88&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2800deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3240deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* 3240 / 360 = 9 */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Tails: lands face-down (odd half-turns = 3060° = 8.5 rotations) */&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;spinTails&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;35&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1080deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;65&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2160deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-10deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;88&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2800deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3060deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* 3060 / 360 = 8.5 */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Heads lands on an &lt;em&gt;even&lt;/em&gt; number of half-turns (front face visible)&lt;/li&gt;
&lt;li&gt;Tails lands on an &lt;em&gt;odd&lt;/em&gt; number of half-turns (back face visible, thanks to &lt;code&gt;backface-visibility: hidden&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The subtle &lt;code&gt;rotateX&lt;/code&gt; wobble at 35%/65% simulates the coin not spinning on a perfectly flat axis — just like real physics&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Layer 3: Ground Shadow
&lt;/h2&gt;

&lt;p&gt;A radial-gradient ellipse that shrinks when the coin is airborne and expands on landing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;shadowPulse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;10&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;30&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c"&gt;/* coin at height */&lt;/span&gt;
    &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c"&gt;/* apex */&lt;/span&gt;
    &lt;span class="err"&gt;70&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c"&gt;/* falling */&lt;/span&gt;
    &lt;span class="err"&gt;85&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;92&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;130px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* impact overshoot */&lt;/span&gt;
    &lt;span class="err"&gt;96&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;110px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* settle */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shadow grounds the coin in 3D space. Without it, the coin feels like it's floating.&lt;/p&gt;




&lt;h2&gt;
  
  
  The JavaScript: 150 Lines, Zero Dependencies
&lt;/h2&gt;

&lt;p&gt;The entire logic is a self-contained IIFE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ANIM_DURATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1450&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// matches CSS 1.4s + buffer&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFlipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flipCoin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isFlipping&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;isFlipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Fair 50/50 random&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Reset animations (remove class → force reflow → add class)&lt;/span&gt;
        &lt;span class="nx"&gt;coin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-tails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;coinWrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tossing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;coin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// force reflow — critical!&lt;/span&gt;

        &lt;span class="c1"&gt;// Trigger all 3 animation layers simultaneously&lt;/span&gt;
        &lt;span class="nx"&gt;coinWrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tossing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;coinShadow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;airborne&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;coin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spin-tails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Update state after animation completes&lt;/span&gt;
        &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;heads&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tails&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="c1"&gt;// Streak tracking&lt;/span&gt;
            &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;lastResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;updateStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nx"&gt;isFlipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ANIM_DURATION&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Keyboard shortcut: Space to flip&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Space&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nf"&gt;flipCoin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key decisions:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;void coin.offsetWidth&lt;/code&gt;&lt;/strong&gt; — Forces a browser reflow between removing and re-adding animation classes. Without this, the browser optimizes away the class change and the animation won't replay.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;isFlipping&lt;/code&gt; guard&lt;/strong&gt; — Prevents spamming the button during animation. CSS animations can stack weirdly if retriggered mid-flight.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Math.random() &amp;lt; 0.5&lt;/code&gt;&lt;/strong&gt; — Simple and fair. The browser's PRNG is seeded from OS entropy, making it perfectly adequate for a coin flip (this isn't cryptography).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Streak tracking&lt;/strong&gt; — A single variable that resets on result change. Elegant and zero-allocation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Coin Face Structure (HTML)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-shadow"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"coin-shadow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-wrapper"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"coin-wrapper"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"coin"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-face coin-heads"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-shine"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                H
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-face coin-tails"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"coin-shine"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                T
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.coin-tails&lt;/code&gt; face has &lt;code&gt;transform: rotateY(180deg)&lt;/code&gt; so it's initially hidden by &lt;code&gt;backface-visibility: hidden&lt;/code&gt;. The shine overlay is a CSS gradient that gives the coin a metallic reflection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stats &amp;amp; Probability Bar
&lt;/h2&gt;

&lt;p&gt;A dead-simple probability visualization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateStats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tails&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;headsBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tailsBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s inside a flex container with &lt;code&gt;transition: width 0.5s&lt;/code&gt; — the bar animates smoothly as the probability shifts with each flip. Over 100+ flips, you watch it converge to 50/50 (law of large numbers in real time).&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Live
&lt;/h2&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://toolknit.com/tools/coin-flip.html" rel="noopener noreferrer"&gt;ToolKnit Coin Flip&lt;/a&gt;&lt;/strong&gt; — free, no signup, runs entirely in your browser.&lt;/p&gt;

&lt;p&gt;The full source is visible in-browser (View Source) since it's all client-side HTML/CSS/JS. Feel free to learn from it, fork the approach, or adapt it for your own projects.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web Animations API&lt;/strong&gt; instead of CSS keyframes — more control over playback speed, easing, and cancellation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas/WebGL&lt;/strong&gt; for truly custom coin textures (country flags, custom images)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haptic feedback&lt;/strong&gt; on mobile via &lt;code&gt;navigator.vibrate()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But for a zero-dependency, 150-line solution that works on every browser? Pure CSS animations are hard to beat.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built as part of &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a free, open-source collection of 60+ browser-based tools. No uploads, no accounts, works offline.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
