<?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>How I Built a Dice Roller That Google Can't Beat — A Technical Deep Dive</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Wed, 08 Jul 2026 08:48:15 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/how-i-built-a-dice-roller-that-google-cant-beat-a-technical-deep-dive-3i44</link>
      <guid>https://dev.to/dngzihng114379/how-i-built-a-dice-roller-that-google-cant-beat-a-technical-deep-dive-3i44</guid>
      <description>&lt;p&gt;Google's built-in dice roller was stealing my search traffic. Instead of competing, I built something it can't replicate: batch rolling, game presets, probability visualization, CSV export, and 3D CSS dice faces. Here's the full technical breakdown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1j1cdbxsyx99b7jkn5a.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%2Fm1j1cdbxsyx99b7jkn5a.png" alt=" " width="800" height="966"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  How I Built a Dice Roller That Google Can't Beat
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Google was stealing my clicks.&lt;/strong&gt; Every time someone searched "dice roller," Google's own interactive dice widget sat at the top of the results — a fully functional, zero-click tool that let users roll dice without ever visiting a website.&lt;/p&gt;

&lt;p&gt;This is the story of how I analyzed the competitive landscape, identified what Google's built-in tool &lt;em&gt;couldn't&lt;/em&gt; do, and built a dice roller that serves real users instead of competing with a search giant head-on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Discovery
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt;, a collection of 90 free browser-based tools. Our dice roller had been live for months — it supported D4 through D100, custom sides, multi-dice rolls, and had a clean dark UI. But the click-through rate from Google Search was abysmal.&lt;/p&gt;

&lt;p&gt;When I looked at the SERP for "dice roller," the answer was obvious:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftoolknit.com%2Fassets%2Fimg%2Fog-image.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%2Ftoolknit.com%2Fassets%2Fimg%2Fog-image.png" alt="Google SERP for dice roller" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Google displays an &lt;strong&gt;interactive dice widget&lt;/strong&gt; directly in the search results. Users can click it, roll dice, and get results — all without leaving Google. This is a SERP Feature specifically designed for dice-related queries, and it cannibalizes organic traffic almost completely.&lt;/p&gt;

&lt;p&gt;The conventional SEO playbook says: &lt;em&gt;"Optimize your meta tags, build more backlinks, write better content."&lt;/em&gt; But none of that matters when Google puts a working tool &lt;em&gt;above&lt;/em&gt; your link. Users don't scroll down because they don't need to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The insight:&lt;/strong&gt; Stop trying to outrank Google. Instead, build something Google's widget &lt;em&gt;can't&lt;/em&gt; do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: What Google's Dice Can't Do
&lt;/h2&gt;

&lt;p&gt;Google's built-in dice roller is minimal by design. It supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;D4, D6, D8, D10, D12, D20, D100&lt;/li&gt;
&lt;li&gt;Multiple dice (e.g., 3d6)&lt;/li&gt;
&lt;li&gt;Adding modifiers (+3, +5)&lt;/li&gt;
&lt;li&gt;An animation that shows results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it &lt;strong&gt;doesn't&lt;/strong&gt; support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Game-specific presets (Monopoly, Yahtzee, Catan, Craps)&lt;/li&gt;
&lt;li&gt;❌ Batch rolling (10×, 50×, 100×)&lt;/li&gt;
&lt;li&gt;❌ Roll history with CSV export&lt;/li&gt;
&lt;li&gt;❌ Probability distribution visualization&lt;/li&gt;
&lt;li&gt;❌ Dice face customization (skins)&lt;/li&gt;
&lt;li&gt;❌ Keyboard shortcuts&lt;/li&gt;
&lt;li&gt;❌ Sound effects&lt;/li&gt;
&lt;li&gt;❌ Session statistics (total rolls, average, grand sum)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every feature on this list is something a &lt;em&gt;real&lt;/em&gt; user would want — whether they're a D&amp;amp;D player tracking combat rounds, a teacher running a probability lesson, or a board game enthusiast who lost their physical dice.&lt;/p&gt;

&lt;p&gt;I decided to build all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The Technical Build
&lt;/h2&gt;

&lt;p&gt;Here's the architecture: &lt;strong&gt;zero dependencies, pure vanilla JavaScript, CSS animations, and HTML5 Canvas.&lt;/strong&gt; No npm, no build step, no framework. The tool is a single HTML file with inline JS that loads in under 300ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 3D Dice Faces with Pure CSS
&lt;/h3&gt;

&lt;p&gt;The dice aren't images or SVGs — they're styled &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; elements with CSS grid layouts. Each face shows the correct dot pattern:&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="nc"&gt;.dice-face&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;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&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;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a1a2e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.dot&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;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;place-self&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The roll animation applies a CSS transform sequence — rotation in 3D space with a scale 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;diceRoll&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;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="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;rotateZ&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;scale&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;25&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;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;180deg&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;90deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateZ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;45deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&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="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;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;360deg&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;180deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateZ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;90deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;75&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;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;540deg&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;270deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateZ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.05&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;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;720deg&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;360deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotateZ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;180deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each die gets a different animation delay so they tumble sequentially, not simultaneously — this creates a much more satisfying visual cascade.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 requestAnimationFrame for Batch Rolling
&lt;/h3&gt;

&lt;p&gt;The batch roll feature was the hardest technical challenge. Rolling 100 dice at once with the full animation loop would freeze the browser. The solution: &lt;strong&gt;chunked processing with &lt;code&gt;requestAnimationFrame&lt;/code&gt;.&lt;/strong&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;function&lt;/span&gt; &lt;span class="nf"&gt;batchRoll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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;isRolling&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;isRolling&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;batchResults&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;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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Process 10 rolls per frame to avoid UI freeze&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;chunk&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;min&lt;/span&gt;&lt;span class="p"&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;count&lt;/span&gt; &lt;span class="o"&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;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;j&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;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&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;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;var&lt;/span&gt; &lt;span class="nx"&gt;d&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;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;diceCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&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="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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;batchResults&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;results&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;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&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="nx"&gt;b&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&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;rolls&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;grandTotal&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="nx"&gt;results&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="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;distribution&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;v&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="p"&gt;});&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="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Update UI every frame&lt;/span&gt;
    &lt;span class="nf"&gt;updateDistribution&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;updateHistory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;progressBar&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="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;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;count&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="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="k"&gt;if &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;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&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;// Final animation for the last result&lt;/span&gt;
      &lt;span class="nf"&gt;renderDice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batchResults&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;batchResults&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;isRolling&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="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&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;Key decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10 rolls per frame&lt;/strong&gt; keeps the UI responsive while maintaining visual feedback&lt;/li&gt;
&lt;li&gt;The progress bar updates every frame, giving users a satisfying sense of progress&lt;/li&gt;
&lt;li&gt;The last roll gets the full dice animation treatment so it "feels" like a real roll&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;isRolling&lt;/code&gt; gate prevents double-clicks from stacking batch operations&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3.3 Game Presets
&lt;/h3&gt;

&lt;p&gt;Instead of asking users to configure dice manually, I added one-click presets for popular games:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;Dice&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;Monopoly&lt;/td&gt;
&lt;td&gt;2d6&lt;/td&gt;
&lt;td&gt;Classic property-buying rolls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yahtzee&lt;/td&gt;
&lt;td&gt;5d6&lt;/td&gt;
&lt;td&gt;Five dice, three rolls per turn (manual)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catan&lt;/td&gt;
&lt;td&gt;2d6&lt;/td&gt;
&lt;td&gt;Settlers resource distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Craps&lt;/td&gt;
&lt;td&gt;2d6&lt;/td&gt;
&lt;td&gt;Casino-style come-out roll&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&amp;amp;D Skill&lt;/td&gt;
&lt;td&gt;1d20&lt;/td&gt;
&lt;td&gt;Ability checks and saving throws&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each preset adjusts the dice count and type automatically. This small UX decision matters enormously for casual users who just want to play their game without reading documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Probability Distribution with Theoretical Comparison
&lt;/h3&gt;

&lt;p&gt;The distribution chart uses &lt;strong&gt;HTML5 Canvas&lt;/strong&gt; to draw a bar chart showing the frequency of each die face (1-6). But the real insight was adding a &lt;strong&gt;theoretical overlay:&lt;/strong&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;// Theoretical: exactly 1/6 = 16.67% per face&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;theoretical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;6&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;theoreticalHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxBarHeight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;theoretical&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(59, 130, 246, 0.25)&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="nx"&gt;barX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chartH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;theoreticalHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;barW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theoreticalHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each bar shows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Actual frequency&lt;/strong&gt; (solid white/gray bar)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Theoretical 16.67%&lt;/strong&gt; (semi-transparent blue overlay)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As users roll more, the bars converge toward the theoretical line — visually demonstrating the Law of Large Numbers. This is useful for teachers and anyone curious about probability.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.5 CSV Export
&lt;/h3&gt;

&lt;p&gt;Roll history is more useful when you can take it elsewhere. The CSV export button generates a downloadable file with headers:&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;exportCSV&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;csv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Roll#,Die1,Die2,Die3,Die4,Die5,Die6,Total&lt;/span&gt;&lt;span class="se"&gt;\n&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="nx"&gt;historyData&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="mi"&gt;1&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;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;--&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;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;historyData&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;csv&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roll&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;row&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;join&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="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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/csv&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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;a&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;a&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&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="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dice-rolls.csv&lt;/span&gt;&lt;span class="dl"&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;click&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 lets users analyze their rolls in Excel, Google Sheets, or any statistics tool. Combined with the batch roll feature, you can generate thousands of data points for a probability experiment in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.6 Dice Skins
&lt;/h3&gt;

&lt;p&gt;Three color themes (slate, indigo, emerald) that apply via CSS class switching. Under the hood, it's just swapping &lt;code&gt;data-skin&lt;/code&gt; attributes and re-rendering. The DOM stays unchanged — only the visual presentation changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.7 Keyboard Shortcuts
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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="kd"&gt;function&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="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; &lt;/span&gt;&lt;span class="dl"&gt;'&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;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;Enter&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;roll&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;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;r&lt;/span&gt;&lt;span class="dl"&gt;'&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;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;R&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;reset&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;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;ArrowUp&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;changeDice&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="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;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;ArrowDown&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;changeDice&lt;/span&gt;&lt;span class="p"&gt;(&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="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;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;1&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;batchRoll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;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;5&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;batchRoll&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="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;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;0&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;batchRoll&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="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;Space/Enter to roll, R to reset, arrows to adjust dice count, number keys for batch operations. Power users love this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: The Results
&lt;/h2&gt;

&lt;p&gt;After the update, the dice roller went from a generic tool to a feature-rich dice toolbox. Key metrics:&lt;/p&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;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly Google impressions&lt;/td&gt;
&lt;td&gt;~200&lt;/td&gt;
&lt;td&gt;2,230+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly Bing impressions&lt;/td&gt;
&lt;td&gt;~50&lt;/td&gt;
&lt;td&gt;1,300+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total weekly exposure&lt;/td&gt;
&lt;td&gt;~250&lt;/td&gt;
&lt;td&gt;3,500+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a &lt;strong&gt;14× increase&lt;/strong&gt; in three months.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;Google's SERP Features aren't an impenetrable wall.&lt;/strong&gt; They're a constraint that forces you to build something genuinely better. If Google's widget does A, B, and C — build A through Z. Users will find you when they need more than what the search result page provides.&lt;/p&gt;




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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers for batch rolling.&lt;/strong&gt; Currently, &lt;code&gt;requestAnimationFrame&lt;/code&gt; is good enough for 100 rolls, but 1,000+ rolls should be offloaded to a Worker thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IndexedDB for persistent roll history.&lt;/strong&gt; Right now, history resets on page reload. Storing it locally would make the tool feel like a real app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Physics-based 3D dice.&lt;/strong&gt; The CSS animations look good, but a Three.js or Canvas-based physics simulation would be the ultimate differentiator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiplayer sync.&lt;/strong&gt; Imagine a shared dice room where multiple players see the same roll in real-time via WebSockets.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;This approach applies to any tool that faces Google SERP Feature competition: calculators, unit converters, timers, coin flippers, color pickers. The strategy is always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Analyze what the SERP Feature does.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;List everything it &lt;em&gt;doesn't&lt;/em&gt; do that real users need.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build those things.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Don't try to outrank Google. Out-build them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was written by the creator of &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — 90 free browser-based tools that run entirely in your browser with no sign-up and no uploads. Try the &lt;a href="https://toolknit.com/tools/dice-roller.html" rel="noopener noreferrer"&gt;Dice Roller&lt;/a&gt; and let me know what else you'd want to see.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxs224exkjokogtc65ip.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%2Ffxs224exkjokogtc65ip.png" alt=" " width="799" height="693"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc3oz5bdvf6ryjb5y7m60.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%2Fc3oz5bdvf6ryjb5y7m60.png" alt=" " width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxlxtf72krf7044ogt6qe.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%2Fxlxtf72krf7044ogt6qe.png" alt=" " width="800" height="1016"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>css</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Built a Free Breathing Relaxation Tool with 6 Science-Backed Techniques — Here's How It Works and Why You Should Try It</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Mon, 06 Jul 2026 07:09:54 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-built-a-free-breathing-relaxation-tool-with-6-science-backed-techniques-heres-how-it-works-and-4c14</link>
      <guid>https://dev.to/dngzihng114379/i-built-a-free-breathing-relaxation-tool-with-6-science-backed-techniques-heres-how-it-works-and-4c14</guid>
      <description>&lt;p&gt;Stress is a silent killer. The WHO estimates that stress-related disorders affect over 300 million people worldwide. We've all been there — deadlines piling up, notifications buzzing, your chest tightening, and no clear way to reset.&lt;/p&gt;

&lt;p&gt;As a developer who also struggles with stress and anxiety, I wanted to build something genuinely useful — not another productivity app or to-do list. Something that could help in the moment. So I built &lt;strong&gt;Breathing Relaxation&lt;/strong&gt;, a free, privacy-first browser tool with 6 guided breathing techniques, now live at &lt;a href="https://toolknit.com/tools/breathing-relaxation.html" rel="noopener noreferrer"&gt;toolknit.com/tools/breathing-relaxation.html&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In this post, I'll cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why breathing exercises work (the science)&lt;/li&gt;
&lt;li&gt;The 6 techniques and when to use each one&lt;/li&gt;
&lt;li&gt;How I built it (Canvas animation, Web Audio API, haptic feedback)&lt;/li&gt;
&lt;li&gt;Key UI/UX decisions that made the experience work&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Breathing Works: A Quick Science Primer
&lt;/h2&gt;

&lt;p&gt;When you're stressed, your sympathetic nervous system kicks in — heart rate rises, breathing becomes shallow, cortisol floods your bloodstream. This is the "fight or flight" response, hardwired into our biology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow, controlled breathing activates the parasympathetic nervous system&lt;/strong&gt; — your body's built-in "rest and digest" mode. The vagus nerve, which runs from your brainstem to your abdomen, detects slow exhalations and signals your heart to slow down. This is called &lt;strong&gt;respiratory sinus arrhythmia&lt;/strong&gt; — and it's the physiological mechanism behind why deep breathing actually works.&lt;/p&gt;

&lt;p&gt;A 2017 meta-analysis published in &lt;em&gt;Frontiers in Psychology&lt;/em&gt; reviewed 15 randomized controlled trials and found that slow-paced breathing (around 6 breaths per minute) significantly reduced self-reported stress, anxiety, and depression scores. Another study in &lt;em&gt;Cell Reports Medicine&lt;/em&gt; (2023) demonstrated that deliberate breathwork practices produced measurable changes in brain connectivity patterns associated with emotion regulation.&lt;/p&gt;

&lt;p&gt;The key: &lt;strong&gt;exhale longer than you inhale&lt;/strong&gt;. That's the signal your vagus nerve needs to trigger relaxation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 6 Techniques — When to Use Each
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. 4-7-8 Breathing (Dr. Andrew Weil)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pattern:&lt;/strong&gt; Inhale 4s → Hold 7s → Exhale 8s&lt;/p&gt;

&lt;p&gt;This is the "nuclear option" for anxiety. The extended exhale (8 seconds) forces maximum vagal activation. Dr. Weil calls it a "natural tranquilizer for the nervous system." It's particularly effective for falling asleep — many users report drifting off within 2-3 cycles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Insomnia, acute anxiety, panic moments.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Box Breathing (Navy SEAL Technique)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pattern:&lt;/strong&gt; Inhale 4s → Hold 4s → Exhale 4s → Hold 4s&lt;/p&gt;

&lt;p&gt;Used by Navy SEALs before high-pressure missions, this technique creates a rhythmic, predictable pattern that forces your mind into a focused state. The equal-phase structure makes it easy to visualize as a square — each side equal in length.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Pre-meeting jitters, public speaking, performance anxiety, regaining focus.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Coherent Breathing (5-5)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pattern:&lt;/strong&gt; Inhale 5s → Exhale 5s&lt;/p&gt;

&lt;p&gt;At exactly 5 breaths per minute, this rhythm synchronizes heart rate, blood pressure, and respiration into a phenomenon called &lt;strong&gt;cardiorespiratory coherence&lt;/strong&gt;. Research from the HeartMath Institute shows that this state measurably improves cognitive performance and emotional stability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Daily maintenance, meditation, sustained focus work.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. 4-2-4 Relaxation
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pattern:&lt;/strong&gt; Inhale 4s → Hold 2s → Exhale 4s&lt;/p&gt;

&lt;p&gt;A gentler introduction to breath holds. The 2-second hold teaches your body to tolerate slight CO2 buildup without triggering panic — a skill that transfers to real-world stressful situations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Beginners, quick resets between meetings, afternoon slumps.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Energizing Breath
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pattern:&lt;/strong&gt; Inhale 6s → Hold 2s → Exhale 4s&lt;/p&gt;

&lt;p&gt;This is the reverse of most relaxation patterns — inhale longer than exhale. It increases oxygen saturation and creates a mild stimulatory effect. Think of it as a natural espresso shot without the crash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Morning energy, pre-workout, beating the 3pm slump.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Alternate Nostril Breathing (Nadi Shodhana)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pattern:&lt;/strong&gt; Inhale left 4s → Hold 4s → Exhale right 4s → Inhale right 4s → Hold 4s → Exhale left 4s&lt;/p&gt;

&lt;p&gt;Rooted in yogic tradition and validated by modern research, this technique balances the left and right hemispheres of the brain. A 2018 study in the &lt;em&gt;International Journal of Yoga&lt;/em&gt; found that regular alternate nostril breathing significantly reduced stress and improved attention in healthy adults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Mental clarity, balancing energy, pre-meditation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tech Stack: How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Canvas Animation Engine
&lt;/h3&gt;

&lt;p&gt;The breathing animation is powered by pure Canvas API — no libraries, no dependencies. Here's the core approach:&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;// Simplified animation loop&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phaseDuration&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;progress&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="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Smooth expand/contract&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;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;canvas&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="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;save&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;translate&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;2&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;2&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;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Draw the breathing circle&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;arc&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;radius&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&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;restore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animate&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 key design challenge was creating smooth, natural-feeling animations. Linear scaling feels mechanical and jarring. I used &lt;code&gt;Math.sin()&lt;/code&gt; with a half-cycle for each phase — inhale maps to &lt;code&gt;0 → π&lt;/code&gt; (expanding), exhale to &lt;code&gt;π → 0&lt;/code&gt; (contracting). The result is a fluid, organic breathing motion that feels intuitive to follow.&lt;/p&gt;

&lt;p&gt;Each technique has its own phase configuration:&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;techniques&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;4-7-8&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;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Inhale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expand&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hold&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Exhale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contract&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;box&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;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Inhale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expand&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hold&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Exhale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contract&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hold&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="c1"&gt;// ... etc&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Phase Timer &amp;amp; Visual Rhythm
&lt;/h3&gt;

&lt;p&gt;Below the animation circle, I built a compact timeline that shows the current phase, remaining time, and overall cycle progress. This was actually the trickiest part — on smaller screens, the labels for a 6-phase technique like Alternate Nostril Breathing would wrap and break the layout.&lt;/p&gt;

&lt;p&gt;The solution: a flexbox row with &lt;code&gt;flex: 0 0 X%&lt;/code&gt; on each phase label (where X = 100 / numberOfPhases), &lt;code&gt;white-space: nowrap&lt;/code&gt;, and &lt;code&gt;text-align: center&lt;/code&gt;. No wrapping, no misalignment — even on mobile.&lt;/p&gt;

&lt;h3&gt;
  
  
  Web Audio API for Sound Cues
&lt;/h3&gt;

&lt;p&gt;When enabled, the tool plays subtle audio cues at phase transitions. I used the Web Audio API's &lt;code&gt;OscillatorNode&lt;/code&gt; to generate a soft, bell-like tone:&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;playPhaseBeep&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&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;osc&lt;/span&gt; &lt;span class="o"&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;createOscillator&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;gain&lt;/span&gt; &lt;span class="o"&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;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;880&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// A5 — soft and pleasant&lt;/span&gt;
    &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&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;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exponentialRampToValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.001&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;currentTime&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="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&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;currentTime&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deliberately chose a high-frequency sine wave (A5, 880Hz) at very low volume — it's audible but never startling. The &lt;code&gt;exponentialRampToValueAtTime&lt;/code&gt; creates a natural decay that feels like a soft chime rather than an alert.&lt;/p&gt;

&lt;h3&gt;
  
  
  Haptic Feedback (iOS)
&lt;/h3&gt;

&lt;p&gt;On supported devices, the tool uses the Vibration API to provide tactile cues:&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vibrate&lt;/span&gt;&lt;span class="p"&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="nf"&gt;vibrate&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="mi"&gt;30&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="c1"&gt;// short-long-short pattern&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 especially useful when using the tool with eyes closed — the vibration confirms phase transitions without breaking the meditative state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cycle Locking Safety Mechanism
&lt;/h3&gt;

&lt;p&gt;One insight from early testing: if you change the number of cycles mid-session, it corrupts the session state. The fix was straightforward — once breathing starts, the +/- buttons are disabled with &lt;code&gt;pointer-events: none&lt;/code&gt; and reduced opacity:&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;lockCycles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enabled&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;btns&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;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;.cycle-btn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;btns&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;btn&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;btn&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;pointerEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&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;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;btn&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;opacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.35&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;1&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;h3&gt;
  
  
  Session Completion UX
&lt;/h3&gt;

&lt;p&gt;When a session ends, instead of an abrupt stop or a modal, I chose a gentle completion message that replaces the phase timer text with a congratulatory note. The font scales responsively (&lt;code&gt;clamp(.85rem, 2vw, 1.1rem)&lt;/code&gt;) and appears at 50% opacity — present but not demanding attention. The user can simply close their eyes, breathe naturally, and let the moment settle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design Philosophy
&lt;/h2&gt;

&lt;p&gt;A few principles that guided the design:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Distraction-free by default.&lt;/strong&gt; The UI is minimal — one circle, one progress bar, one text label. You don't need to look at the screen to use it. Sound and vibration are opt-in, not opt-out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Privacy-first.&lt;/strong&gt; Zero data leaves your browser. No accounts, no tracking, no analytics on the tool page. Your breathing patterns are yours alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Accessibility matters.&lt;/strong&gt; All color choices passed WCAG AA contrast ratios. The Phosphor icon set replaced emojis to ensure consistent rendering across platforms.&lt;/p&gt;




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

&lt;p&gt;The tool is free and lives at &lt;strong&gt;&lt;a href="https://toolknit.com/tools/breathing-relaxation.html" rel="noopener noreferrer"&gt;toolknit.com/tools/breathing-relaxation.html&lt;/a&gt;&lt;/strong&gt;. No sign-up, no download — just open it in any browser.&lt;/p&gt;

&lt;p&gt;I built this for myself. I use it before stressful meetings, when I can't fall asleep, and during those moments when the world feels a little too heavy. If it helps even one other person breathe a little easier, the late nights were worth it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with vanilla JavaScript, Canvas API, Web Audio API, and a lot of deep breaths. Questions? Feedback? Reach me at &lt;a href="mailto:2645149786@qq.com"&gt;2645149786@qq.com&lt;/a&gt; or &lt;a href="https://x.com/dngzihng114379" rel="noopener noreferrer"&gt;@dngzihng114379&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Zaccaro, A., et al. (2018). "How Breath-Control Can Change Your Life: A Systematic Review on Psycho-Physiological Correlates of Slow Breathing." &lt;em&gt;Frontiers in Human Neuroscience&lt;/em&gt;, 12, 353.&lt;/li&gt;
&lt;li&gt;Balban, M. Y., et al. (2023). "Brief structured respiration practices enhance mood and reduce physiological arousal." &lt;em&gt;Cell Reports Medicine&lt;/em&gt;, 4(1), 100895.&lt;/li&gt;
&lt;li&gt;Telles, S., et al. (2018). "Alternate Nostril Breathing at Different Rates and its Influence on Heart Rate Variability in Non-Practitioners of Yoga." &lt;em&gt;International Journal of Yoga&lt;/em&gt;, 11(1), 46–52.&lt;/li&gt;
&lt;li&gt;McCraty, R., &amp;amp; Zayas, M. A. (2014). "Cardiac coherence, self-regulation, autonomic stability, and psychosocial well-being." &lt;em&gt;Frontiers in Psychology&lt;/em&gt;, 5, 1090.&lt;/li&gt;
&lt;li&gt;Weil, A. (2015). &lt;em&gt;Breathing: The Master Key to Self Healing&lt;/em&gt;. Sounds True.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>mentalhealth</category>
    </item>
    <item>
      <title>I Built 89 Tools in 100 Days. AI Wrote 90% of the Code. I Don't Regret It.</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sun, 05 Jul 2026 09:39:17 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-built-89-tools-in-100-days-ai-wrote-90-of-the-code-i-dont-regret-it-563j</link>
      <guid>https://dev.to/dngzihng114379/i-built-89-tools-in-100-days-ai-wrote-90-of-the-code-i-dont-regret-it-563j</guid>
      <description>&lt;p&gt;The 'hand-written code backlash' misses the point. Here's what actually happens when one developer ships a full SaaS with AI — and what the critics get wrong.&lt;/p&gt;

&lt;h1&gt;
  
  
  I Built 89 Tools in 100 Days. AI Wrote 90% of the Code. I Don't Regret It.
&lt;/h1&gt;

&lt;p&gt;The current dev.to narrative is clear: AI coding is creating a generation of "prompt engineers" who can't debug their own code. The hand-written code backlash is real — a recent HN post arguing for ditching AI coding assistants hit 737 points. "Vibe coding" is a punchline. Senior engineers are writing manifestos about comprehension gaps and skill atrophy.&lt;/p&gt;

&lt;p&gt;I want to offer a counterpoint — not from theory, but from production.&lt;/p&gt;

&lt;p&gt;On March 18, 2026, I launched &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit.com&lt;/a&gt; — a privacy-first online toolbox. 109 days later, the site has &lt;strong&gt;89 browser-based tools&lt;/strong&gt;, &lt;strong&gt;87 blog posts&lt;/strong&gt;, a &lt;strong&gt;DR 41 domain rating&lt;/strong&gt;, and thousands of monthly visitors from Google and Bing. Every tool runs 100% client-side. No backend. No database. No signup.&lt;/p&gt;

&lt;p&gt;AI wrote roughly 90% of the code.&lt;/p&gt;

&lt;p&gt;Here's the part the critics don't tell you: &lt;strong&gt;it worked&lt;/strong&gt;. And I think the backlash, while understandable, is fighting the wrong battle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "90% AI-Generated" Actually Means
&lt;/h2&gt;

&lt;p&gt;Let me be precise, because the numbers matter.&lt;/p&gt;

&lt;p&gt;ToolKnit has 89 tools. Each tool has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An HTML page (~350-600 lines of semantic markup)&lt;/li&gt;
&lt;li&gt;A JavaScript engine (~200-800 lines of logic)&lt;/li&gt;
&lt;li&gt;i18n translations in English and Chinese&lt;/li&gt;
&lt;li&gt;SEO metadata, JSON-LD structured data, Open Graph tags&lt;/li&gt;
&lt;li&gt;A service worker entry&lt;/li&gt;
&lt;li&gt;A sitemap entry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's roughly &lt;strong&gt;55,000 lines of code&lt;/strong&gt; across the project.&lt;/p&gt;

&lt;p&gt;I hand-wrote exactly &lt;strong&gt;zero&lt;/strong&gt; of those lines from scratch. Every file started with an AI prompt. Some took 3 iterations. Some took 15. But the initial scaffolding, the repetitive patterns (tool cards, footer components, meta tags), the JavaScript logic — AI generated all of it.&lt;/p&gt;

&lt;p&gt;But here's the catch: &lt;strong&gt;I read and understood every line.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workflow That Makes AI Actually Work
&lt;/h2&gt;

&lt;p&gt;The anti-AI crowd describes a world where developers paste AI output into production without reading it. I don't know anyone who actually works that way. Here's my real workflow:&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Architecture (Human)
&lt;/h3&gt;

&lt;p&gt;AI cannot design a system. It can't decide that 89 tools should share a common &lt;code&gt;bilingual.js&lt;/code&gt; engine rather than each implementing their own language toggle. It can't plan a service worker caching strategy or decide which CDN to use.&lt;/p&gt;

&lt;p&gt;I made all architectural decisions. I designed the component patterns, the file structure, the CSS custom property system, the build pipeline. This is the work that determines whether a project survives six months or collapses under its own weight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: Scaffolding (AI)
&lt;/h3&gt;

&lt;p&gt;Once the patterns were established, AI became a multiplier. "Create a new tool page following this template, with these features, using this CSS framework." 90 seconds later, I had a complete HTML file with working JavaScript.&lt;/p&gt;

&lt;p&gt;The AI understood the patterns because I'd already established them. It knew the bilingual setup because &lt;a href="https://toolknit.com/assets/js/bilingual.js" rel="noopener noreferrer"&gt;bilingual.js&lt;/a&gt; was part of the project context. It knew the footer pattern, the tool card pattern, the SEO pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: Review &amp;amp; Fix (Human)
&lt;/h3&gt;

&lt;p&gt;This is where the 70% rejection rate from the studies comes in. AI gets things wrong. It hallucinates API names. It generates CSS that looks right but breaks at certain viewports. It writes JavaScript that handles the happy path but ignores edge cases.&lt;/p&gt;

&lt;p&gt;But here's the thing: &lt;strong&gt;reviewing AI-generated code is faster than writing it from scratch.&lt;/strong&gt; The cognitive load of "is this correct?" is lower than "what should I write and is it correct?".&lt;/p&gt;

&lt;p&gt;For ToolKnit, the review phase typically took 5-10 minutes per file. I'd scan for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Logical errors (timer countdowns that don't handle pause correctly)&lt;/li&gt;
&lt;li&gt;Edge cases (what happens when localStorage is full?)&lt;/li&gt;
&lt;li&gt;Security issues (never trust user input, even in client-side tools)&lt;/li&gt;
&lt;li&gt;i18n completeness (did the Chinese translations actually get generated?)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 4: Iterate (Human + AI)
&lt;/h3&gt;

&lt;p&gt;The back-and-forth is where the real work happens. "The progress ring doesn't update when switching tabs." "The mobile layout breaks at 320px." "The Chinese translation for 'health recovery timeline' is awkward."&lt;/p&gt;

&lt;p&gt;Each fix is another AI prompt. Each prompt takes 30 seconds. Each fix takes 2 minutes to verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Critics Get Right
&lt;/h2&gt;

&lt;p&gt;I want to be fair to the backlash. They're not entirely wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI code converges on the median.&lt;/strong&gt; If you let AI design your architecture, you'll get a generic React app with useState and useEffect everywhere. It won't be wrong, but it won't be good. The median is fine for CRUD. It's death for anything novel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The comprehension gap is real.&lt;/strong&gt; When I wrote code by hand ten years ago, I built a mental model of every subsystem. With AI, I have to &lt;em&gt;actively&lt;/em&gt; build that model by reading the generated output. It's a different kind of understanding — more like code review than authorship — but understanding still happens if you're disciplined about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging AI-generated code is harder.&lt;/strong&gt; When you wrote it yourself, you know where the bodies are buried. When AI wrote it, you have to discover them. This is the single biggest productivity drag in my workflow. I've spent 45 minutes debugging a 200-line JavaScript file that AI wrote in 90 seconds. That ratio is real and it's painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Critics Get Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;They assume AI replaces thinking.&lt;/strong&gt; It doesn't. It replaces typing. The thinking — architecture, design decisions, user experience, SEO strategy — all of that still comes from a human. AI is a force multiplier for execution, not a substitute for judgment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They compare AI output to ideal human output.&lt;/strong&gt; The fair comparison is AI output vs. what one developer can actually ship in a given timeframe. I could have built ToolKnit without AI. It would have taken 500 days instead of 100. And in those 400 extra days, I probably would have made just as many mistakes — just slower.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They ignore the solo developer economics.&lt;/strong&gt; I'm one person. I don't have a team. I don't have a QA department. AI is my rubber duck, my code reviewer, my junior developer, and my documentation writer. For a solo founder, the alternative to "AI with careful review" isn't "pristine hand-written code." It's "shipping 15 tools instead of 89."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tools That Prove the Point
&lt;/h2&gt;

&lt;p&gt;Some examples from ToolKnit where AI genuinely accelerated shipping:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://toolknit.com/tools/quit-smoking-tracker.html" rel="noopener noreferrer"&gt;Quit Smoking Tracker&lt;/a&gt;&lt;/strong&gt; — A behavioral reinforcement tool with localStorage persistence, a 10-stage health recovery timeline (based on CDC/WHO research), achievement badges, and bilingual support. The core logic — calculating cigarettes avoided, money saved, life minutes regained — took about 4 prompts to implement. The health timeline component took another 3. Total dev time: ~2 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://toolknit.com/tools/pomodoro-timer.html" rel="noopener noreferrer"&gt;Pomodoro Timer&lt;/a&gt;&lt;/strong&gt; — Circular progress ring with SVG animation, three work/break modes, configurable durations, audio alerts, and tab title countdown. The SVG arc animation logic alone would have taken me an hour to write from scratch. AI generated it in one prompt. I spent 15 minutes fixing edge cases (what happens when the user switches tabs?).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://toolknit.com/tools/pdf-to-image.html" rel="noopener noreferrer"&gt;PDF to Image&lt;/a&gt;&lt;/strong&gt; — Converts PDF pages to PNG/JPG/WebP using pdf.js and canvas. This would have been a multi-day deep dive into the pdf.js API. AI knew the API surface area, generated the rendering pipeline, and handled the format conversion options. I verified correctness and fixed browser-specific rendering bugs.&lt;/p&gt;

&lt;p&gt;The pattern repeats across all 89 tools. AI handles the API integration, the boilerplate, the repetitive patterns. I handle the architecture, the UX decisions, the edge cases, and the quality gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  So Should You Use AI to Write Code?
&lt;/h2&gt;

&lt;p&gt;In 2026, asking "should I use AI to code?" is like asking "should I use a framework?" in 2016. The answer depends on what you're building and who you are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use AI aggressively if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're a solo developer shipping a product&lt;/li&gt;
&lt;li&gt;Your project has clear, repeatable patterns&lt;/li&gt;
&lt;li&gt;You understand the code well enough to review it&lt;/li&gt;
&lt;li&gt;Speed-to-market matters more than architectural elegance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Be skeptical of AI if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're building novel, performance-critical systems&lt;/li&gt;
&lt;li&gt;You're a junior developer who can't evaluate AI output yet&lt;/li&gt;
&lt;li&gt;Your project has complex state management or race conditions&lt;/li&gt;
&lt;li&gt;You're working on security-sensitive infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The critical variable isn't the tool. It's &lt;strong&gt;whether you're capable of reviewing what the tool produces.&lt;/strong&gt; If you can spot the bugs, AI is a multiplier. If you can't, AI is a liability.&lt;/p&gt;

&lt;p&gt;This is why the most dangerous advice in tech right now is "just use AI, you don't need to learn to code." You absolutely need to learn to code. You just might not need to type as much.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;109 days. 89 tools. 87 blog posts. DR 41. Real traffic from Google and Bing. All built by one person with AI assistance.&lt;/p&gt;

&lt;p&gt;The hand-written code crowd isn't wrong about the risks. They're wrong about the conclusion. The goal shouldn't be hand-written code. The goal should be &lt;strong&gt;understood code&lt;/strong&gt; — code where the developer, AI-assisted or not, knows what every line does and why it's there.&lt;/p&gt;

&lt;p&gt;AI didn't build ToolKnit. I built ToolKnit. AI typed faster than I can. That's it. That's the entire magic trick.&lt;/p&gt;

&lt;p&gt;And I don't regret a single prompt.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit.com&lt;/a&gt; is a free, privacy-first online toolbox with 89 browser-based utilities. No uploads, no signup, no ads. All tools run entirely in your browser.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's your experience with AI coding?&lt;/strong&gt; Has it been a multiplier or a liability for your projects? I'm genuinely curious — especially if you're in the "going back to hand-written code" camp. Drop a comment.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>startup</category>
    </item>
    <item>
      <title>From Web App to Native Desktop: Building ToolKnit with Tauri and a Dual-Source Installer</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Thu, 02 Jul 2026 11:08:05 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/from-web-app-to-native-desktop-building-toolknit-with-tauri-and-a-dual-source-installer-4olh</link>
      <guid>https://dev.to/dngzihng114379/from-web-app-to-native-desktop-building-toolknit-with-tauri-and-a-dual-source-installer-4olh</guid>
      <description>&lt;h2&gt;
  
  
  The Problem We Wanted to Solve
&lt;/h2&gt;

&lt;p&gt;Every day, developers, designers, and content creators juggle dozens of small tasks — merge a PDF, convert an audio file, compress an image, generate a QR code, polish some text with AI. The typical workflow? Open a browser, search for an online tool, upload your file, wait, download the result. Repeat for the next task. Repeat for the next tool.&lt;/p&gt;

&lt;p&gt;The problems with this approach are obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt;: You're uploading sensitive documents to random websites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed&lt;/strong&gt;: Browser-based tools are slower than native processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fragmentation&lt;/strong&gt;: Each tool lives on a different site with different UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline&lt;/strong&gt;: No internet, no tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We wanted to build &lt;strong&gt;one desktop application&lt;/strong&gt; that bundles all these utilities together — running locally, respecting privacy, and feeling like a native app.&lt;/p&gt;

&lt;p&gt;This is the story of how &lt;a href="https://toolknit.com/" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; went from a web prototype to a polished Windows &lt;code&gt;.exe&lt;/code&gt; with a custom installer and dual-source CDN deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1: The Web Prototype
&lt;/h2&gt;

&lt;p&gt;Before committing to a desktop framework, we built a web-based prototype to validate the concept. The idea was simple: a single-page app with a sidebar navigation, dark theme, and tool cards for each category.&lt;/p&gt;

&lt;p&gt;The prototype covered &lt;strong&gt;9 tool categories&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Home&lt;/strong&gt; — Dashboard with usage stats and navigation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — Merge, split, compress, encrypt/decrypt, rotate, watermark&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — Format conversion, batch compress, crop, collage, watermark removal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio Tools&lt;/strong&gt; — Format conversion, clipping, BPM detection, channel processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video Tools&lt;/strong&gt; — Format conversion, compression, screenshot extraction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — Full/half-width conversion, case conversion, JSON formatting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calculator Tools&lt;/strong&gt; — Scientific computation, unit conversion, base conversion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creative Tools&lt;/strong&gt; — QR code generation, color picker, password generator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Tools&lt;/strong&gt; — Translation, polishing, document generation, role play&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The web prototype proved the concept, but it had limitations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;File processing required server-side APIs (or WASM, which was complex for video/audio)&lt;/li&gt;
&lt;li&gt;Users had to be online&lt;/li&gt;
&lt;li&gt;No drag-and-drop file system access&lt;/li&gt;
&lt;li&gt;Browser security sandbox limited what we could do with local files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We needed to go native.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2: DR41 — The Tauri Migration
&lt;/h2&gt;

&lt;p&gt;We evaluated several options for the desktop migration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Bundle Size&lt;/th&gt;
&lt;th&gt;Native Feel&lt;/th&gt;
&lt;th&gt;Rust Ecosystem&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Electron&lt;/td&gt;
&lt;td&gt;~150MB&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tauri&lt;/td&gt;
&lt;td&gt;~12MB&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutter Desktop&lt;/td&gt;
&lt;td&gt;~20MB&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We chose &lt;strong&gt;Tauri 2.x&lt;/strong&gt; for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tiny bundle size&lt;/strong&gt; — The final &lt;code&gt;.exe&lt;/code&gt; is ~46MB (including FFmpeg), compared to Electron's typical 150MB+&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rust backend&lt;/strong&gt; — Native performance for file processing, PDF manipulation, and media conversion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web frontend&lt;/strong&gt; — We could reuse our existing HTML/CSS/JS frontend almost entirely&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────┐
│           ToolKnit Desktop              │
├─────────────────────────────────────────┤
│  Frontend (HTML/CSS/JS)                 │
│  ├── Dark theme + WebGL background      │
│  ├── 9 tool category pages              │
│  ├── i18n (zh/en) with nested keys      │
│  └── Spider mascot loading animations   │
├─────────────────────────────────────────┤
│  Tauri Bridge (IPC)                     │
│  ├── invoke() commands                  │
│  ├── Event listeners                    │
│  └── Drag &amp;amp; drop file handling          │
├─────────────────────────────────────────┤
│  Rust Backend                            │
│  ├── PDF: pdf-lib-plus-encrypt          │
│  ├── Audio/Video: FFmpeg (bundled)      │
│  ├── Image: image crate                 │
│  ├── AI: DeepSeek/OpenAI/Qwen APIs      │
│  └── File system access                 │
├─────────────────────────────────────────┤
│  Native Window                          │
│  ├── Custom titlebar (no decorations)   │
│  ├── System tray with i18n menu         │
│  └── 1100x800 fixed size                │
└─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Technical Decisions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Custom window decorations&lt;/strong&gt;: We disabled native window decorations (&lt;code&gt;decorations: false&lt;/code&gt;) and built a custom titlebar with minimize/maximize/close buttons, plus a "always on top" toggle. This gave us full control over the visual design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebGL dynamic background&lt;/strong&gt;: The home page features a custom WebGL shader that renders a flowing dark fluid animation. We optimized it by disabling anti-aliasing and alpha channels for performance:&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;gl&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;webgl&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;antialias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;premultipliedAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;Bundled FFmpeg&lt;/strong&gt;: Audio and video tools require FFmpeg. Instead of asking users to install it separately, we bundle a minimal FFmpeg binary and download it on first launch if needed. The download source is selected based on the user's language (more on this below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;i18n with nested keys&lt;/strong&gt;: We use a nested JSON structure for translations, supporting both Chinese and English:&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;"home"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pdfDecrypt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PDF Decrypt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dropHere"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Drop your PDF here..."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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 language is determined at startup by reading an &lt;code&gt;install_lang.txt&lt;/code&gt; file that the installer writes during setup. This file contains the NSIS language ID (1033 for English, 2052 for Simplified Chinese).&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 3: The Installer
&lt;/h2&gt;

&lt;p&gt;Distributing a Tauri app is straightforward — &lt;code&gt;npm run tauri build&lt;/code&gt; produces an &lt;code&gt;.exe&lt;/code&gt;. But we wanted more control over the installation experience.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom NSIS Installer
&lt;/h3&gt;

&lt;p&gt;We built a separate &lt;strong&gt;Tauri-based installer&lt;/strong&gt; (&lt;code&gt;toolknit-installer&lt;/code&gt;) that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Presents a visual wizard with language selection (Chinese/English)&lt;/li&gt;
&lt;li&gt;Downloads the main program zip from CDN&lt;/li&gt;
&lt;li&gt;Extracts it to the user's chosen path&lt;/li&gt;
&lt;li&gt;Creates desktop shortcuts and start menu entries&lt;/li&gt;
&lt;li&gt;Writes &lt;code&gt;install_config.json&lt;/code&gt; and &lt;code&gt;install_lang.txt&lt;/code&gt; for the main app to read&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The installer itself is a Tauri app too — same tech stack, different purpose. It's only ~11.6MB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dual-Source CDN Deployment
&lt;/h3&gt;

&lt;p&gt;Here's where it gets interesting. We serve users in both China and internationally. Network latency between China and overseas CDNs can be significant.&lt;/p&gt;

&lt;p&gt;We deployed &lt;strong&gt;two object storage sources&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;Source&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R2&lt;/td&gt;
&lt;td&gt;Cloudflare R2&lt;/td&gt;
&lt;td&gt;Global (CDN)&lt;/td&gt;
&lt;td&gt;International/English users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RainS3&lt;/td&gt;
&lt;td&gt;RainS3&lt;/td&gt;
&lt;td&gt;China (cn-nb1)&lt;/td&gt;
&lt;td&gt;Chinese users&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The download URL is selected based on the user's language preference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"zh"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"https://toolknit.cn-nb1.rains3.com/toolknit-desktop.zip"&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="s"&gt;"https://cdn.24picture.com/toolknit-desktop.zip"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sources are kept in sync using a Node.js upload script that pushes to both simultaneously:&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;// Upload zip (main program)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadToClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r2Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r2Bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolknit-desktop.zip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zipPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/zip&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;R2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadToClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rainClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rainBucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolknit-desktop.zip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zipPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/zip&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;RainS3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Upload installer exe&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadToClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r2Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r2Bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolknit-installer.exe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;installerPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/octet-stream&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;R2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadToClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rainClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rainBucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolknit-installer.exe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;installerPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/octet-stream&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;RainS3&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 installer also has a &lt;strong&gt;fallback mechanism&lt;/strong&gt; — if the primary source fails, it automatically tries the other source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}?_t={}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}?_t={}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&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;We append a timestamp parameter to bust CDN cache, ensuring users always get the latest version.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 4: The Download Page
&lt;/h2&gt;

&lt;p&gt;The final piece was a beautiful download page at &lt;a href="https://toolknit.com/" rel="noopener noreferrer"&gt;toolknit.com&lt;/a&gt;. We wanted it to showcase the app's features with smooth parallax scrolling and full bilingual support.&lt;/p&gt;

&lt;h3&gt;
  
  
  Parallax Scrolling with Layered Effects
&lt;/h3&gt;

&lt;p&gt;Each of the 9 tool categories gets its own scrolling module with alternating image/text layouts. We implemented &lt;strong&gt;4 layers of parallax&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hero section&lt;/strong&gt; — Title, subtitle, and description each move at different speeds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module images&lt;/strong&gt; — Images translate at a different rate than the scroll&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module content&lt;/strong&gt; — Text content moves at its own pace, with sub-elements (tag, title, description, features, stats) each having individual &lt;code&gt;data-speed&lt;/code&gt; values&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image scaling&lt;/strong&gt; — Images subtly scale based on scroll position for depth
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each element has a data-speed attribute&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elementCenter&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;viewportCenter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;el&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;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translateY(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offset&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Language-Aware Screenshots
&lt;/h3&gt;

&lt;p&gt;The download page supports both English and Chinese. When the user switches languages, not only does the text change, but the &lt;strong&gt;screenshots change too&lt;/strong&gt; — English screenshots show the English UI, Chinese screenshots show the Chinese UI:&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;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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;1-&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="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;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;.module-image img&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;img&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://24picture.com/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&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="s2"&gt;.webp`&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 language preference is saved to &lt;code&gt;localStorage&lt;/code&gt;, so returning users see their preferred language automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Download Logic
&lt;/h3&gt;

&lt;p&gt;The download buttons on the page follow the same dual-source logic as the installer:&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;DOWNLOAD_URLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://cdn.24picture.com/toolknit-installer.exe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;zh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://toolknit.cn-nb1.rains3.com/toolknit-installer.exe&lt;/span&gt;&lt;span class="dl"&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;handleDownload&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="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;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolknit-lang&lt;/span&gt;&lt;span class="dl"&gt;'&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;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DOWNLOAD_URLS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_blank&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;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Tauri's CSP is strict but worth it
&lt;/h3&gt;

&lt;p&gt;Tauri enforces a strict Content Security Policy. Every external domain your app connects to must be whitelisted in &lt;code&gt;tauri.conf.json&lt;/code&gt;. This forced us to be explicit about our API endpoints and CDN URLs — which is actually a good security practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. File locks on Windows are painful
&lt;/h3&gt;

&lt;p&gt;During development, the Rust compiler would sometimes fail with &lt;code&gt;os error 32&lt;/code&gt; (file in use) because the previous build's &lt;code&gt;.exe&lt;/code&gt; was still running. Our solution: a kill script that runs before every build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;taskkill&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/F&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/IM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;toolknit-desktop.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="bp"&gt;$null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;taskkill&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/F&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/IM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cargo.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="bp"&gt;$null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For first-time full builds, we also set &lt;code&gt;CARGO_BUILD_JOBS=1&lt;/code&gt; to avoid concurrent file access.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Dual-source deployment is simpler than it sounds
&lt;/h3&gt;

&lt;p&gt;Using S3-compatible APIs for both Cloudflare R2 and RainS3 means we can use the same AWS SDK code for both. The only difference is the endpoint and credentials. One script, two uploads, done.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The installer is part of the product
&lt;/h3&gt;

&lt;p&gt;A great app with a terrible install experience still feels bad. Building a custom Tauri-based installer let us control the entire user journey — from download to first launch — with proper language detection and a clean wizard UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Desktop Framework&lt;/td&gt;
&lt;td&gt;Tauri 2.x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;Vanilla HTML/CSS/JS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF Processing&lt;/td&gt;
&lt;td&gt;pdf-lib-plus-encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio/Video&lt;/td&gt;
&lt;td&gt;FFmpeg (bundled)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image Processing&lt;/td&gt;
&lt;td&gt;image crate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI Integration&lt;/td&gt;
&lt;td&gt;DeepSeek, OpenAI, Qwen APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icons&lt;/td&gt;
&lt;td&gt;Lucide Icons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object Storage&lt;/td&gt;
&lt;td&gt;Cloudflare R2 + RainS3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload Script&lt;/td&gt;
&lt;td&gt;Node.js + AWS SDK v3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smooth Scroll&lt;/td&gt;
&lt;td&gt;Lenis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background&lt;/td&gt;
&lt;td&gt;Custom WebGL shader&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;ToolKnit is currently in &lt;strong&gt;v1.0 Beta&lt;/strong&gt; for Windows. We're planning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS and Linux support&lt;/li&gt;
&lt;li&gt;Plugin system for community-contributed tools&lt;/li&gt;
&lt;li&gt;More AI-powered features&lt;/li&gt;
&lt;li&gt;Auto-update mechanism&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;ToolKnit is free and open for everyone. Download the installer for your platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;International users&lt;/strong&gt;: &lt;a href="https://cdn.24picture.com/toolknit-installer.exe" rel="noopener noreferrer"&gt;Download from CDN&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;China users&lt;/strong&gt;: &lt;a href="https://toolknit.cn-nb1.rains3.com/toolknit-installer.exe" rel="noopener noreferrer"&gt;Download from RainS3&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Visit &lt;a href="https://toolknit.com/" rel="noopener noreferrer"&gt;toolknit.com&lt;/a&gt; to see the full feature showcase, or check out our &lt;a href="https://toolknit.com/blog/" rel="noopener noreferrer"&gt;blog&lt;/a&gt; for more development stories.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this article helpful, consider giving it a reaction or following for more posts about desktop development with Tauri, Rust, and modern web technologies.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>rust</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>100 Days, 87 Tools, Zero Servers: What I Learned Building a Fully Client-Side Utility Suite</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Fri, 26 Jun 2026 10:22:35 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/100-days-87-tools-zero-servers-what-i-learned-building-a-fully-client-side-utility-suite-1bh0</link>
      <guid>https://dev.to/dngzihng114379/100-days-87-tools-zero-servers-what-i-learned-building-a-fully-client-side-utility-suite-1bh0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;On March 18, 2026, I launched a side project called &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a collection of free browser-based tools that process everything locally. No uploads, no signup, no backend beyond a tiny PHP stats endpoint. Yesterday was day 100. This is what I learned about architecture, performance, and the limits of "just use the browser."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Premise
&lt;/h2&gt;

&lt;p&gt;Most online tool sites follow the same pattern: upload your file → server processes it → download the result. This means your sensitive documents (PDFs, photos, audio recordings) sit on someone else's server, even if only briefly.&lt;/p&gt;

&lt;p&gt;ToolKnit's premise was simple: &lt;strong&gt;what if every tool ran entirely in the browser?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "mostly client-side with a fallback API." Not "client-side for small files, server for large ones." &lt;strong&gt;Entirely.&lt;/strong&gt; Every PDF merge, every image resize, every audio conversion, every QR code generation — all happening in the user's browser via Web APIs, with zero bytes leaving their device.&lt;/p&gt;

&lt;p&gt;100 days in, the site has &lt;strong&gt;87 tools across 10 categories&lt;/strong&gt;. Here's what worked, what didn't, and what I'd do differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack: Boring on Purpose
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;HTML + Tailwind CSS + Vanilla JS
PHP (stats endpoint only)
scp for deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No React. No Vue. No build step beyond Tailwind compilation. No CI/CD pipeline. No Docker containers. No Kubernetes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why static HTML for 87 tool pages?&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SEO is non-negotiable for a tool site.&lt;/strong&gt; Static HTML means Google crawls everything immediately — no hydration delays, no client-side routing, no JavaScript-required indexing. Every page is a complete document with full Open Graph tags, Twitter Cards, and JSON-LD structured data.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Performance budget is tight.&lt;/strong&gt; Users landing on a "compress PDF" page from Google Search expect the tool to work &lt;em&gt;now&lt;/em&gt;, not after a 2 MB JS bundle downloads and hydrates. Most ToolKnit tools work without any framework — progressive enhancement is the default.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Server costs are near zero.&lt;/strong&gt; The only PHP endpoints are &lt;code&gt;track.php&lt;/code&gt; (receives usage beacons) and &lt;code&gt;public-stats.php&lt;/code&gt; (serves aggregated metrics). Everything else — PDF manipulation via &lt;code&gt;pdf-lib.js&lt;/code&gt;, image processing via Canvas API, audio encoding via Web Audio API, video transcoding via &lt;code&gt;ffmpeg.wasm&lt;/code&gt; — runs in the browser.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; Manual consistency across 87 HTML files. When you add a new footer link, you're editing 87 files (or writing a Python script to do it, then verifying each one). There's no component system to save you.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Works in the Browser
&lt;/h2&gt;

&lt;h3&gt;
  
  
  PDF Processing — Better Than Expected
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pdf-lib.js&lt;/code&gt; handles PDF merge, split, compress, and page-to-image conversion entirely client-side. The only server-side fallback is &lt;code&gt;pdf-to-word.php&lt;/code&gt; for extreme edge cases where client-side extraction fails — and even that strips identifying metadata before processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surprise:&lt;/strong&gt; PDF compression was the hardest. "Compress" sounds simple until you realize it means recompressing embedded images at lower JPEG quality, removing duplicate font subsets, and stripping unused objects — all without corrupting the file structure. The browser does this well, but the implementation is 400+ lines of careful stream manipulation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Image Tools — Canvas API Is Underrated
&lt;/h3&gt;

&lt;p&gt;17 image tools run on plain Canvas API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resize, crop, grid split, format conversion (JPG/PNG/WebP), circle crop, silk-screen halftone effect, background removal (via color thresholding), HEIC conversion (via &lt;code&gt;heic2any&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;imageSmoothingEnabled = false&lt;/code&gt; flag is your best friend for pixel-perfect operations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;willReadRecently: true&lt;/code&gt; on &lt;code&gt;getContext('2d')&lt;/code&gt; eliminates console warnings when you call &lt;code&gt;getImageData&lt;/code&gt; frequently&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Audio — Web Audio API Is Powerful but Quirky
&lt;/h3&gt;

&lt;p&gt;A MIDI keyboard with 8 synthesized timbres (Grand Piano, Pipa, Guitar, Flute, Music Box, and more) — all generated in real-time through oscillator nodes, custom harmonics, and resonant filters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key learning:&lt;/strong&gt; "Piano sound" isn't one oscillator. Each timbre required distinct synthesis architecture. The Pipa (琵琶) timbre needed sawtooth + square waves with non-integer harmonics (5.04×, 7.01× fundamental) to simulate the metallic twang of plucked silk strings, plus a noise burst layer for the finger-pick attack transient. Without that noise burst, it sounds like a cheap synth preset.&lt;/p&gt;

&lt;h3&gt;
  
  
  Video — ffmpeg.wasm Works, But...
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ffmpeg.wasm&lt;/code&gt; enables browser-side video compression and format conversion. It works. But the ~32 MB one-time Worker download is a real cost on slow connections. I'm still evaluating whether this belongs in a "zero upload" toolkit or whether the download size undermines the value proposition.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Doesn't Work in the Browser
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Live Photo Frame — Killed After 48 Hours
&lt;/h3&gt;

&lt;p&gt;The idea: frame iPhone Live Photos (HEIC + MOV pairs) and Android Motion Photos in elegant layouts, export to video.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three fatal flaws:&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 corner radii above ~60px, "rounded corners" morph into chamfered edges. The visual signature of the tool was mathematically impossible with the Canvas API I was using. (The fix is &lt;code&gt;arc()&lt;/code&gt; with tangent calculations, but by then I'd lost confidence in the concept.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HEVC/H.265 videos from Android Motion Photos don't play natively in Chrome.&lt;/strong&gt; ffmpeg.wasm transcoding at 1080×1920 was 30+ seconds for a 3-second clip. Users don't wait 30 seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;WeChat strips 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 core use case didn't exist in the real world.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&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;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  AI-Generated Pixel Art — Twice Bitten
&lt;/h3&gt;

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

&lt;p&gt;Second attempt: Local Canvas pixelation + &lt;code&gt;gif.js&lt;/code&gt; encoding + color analysis. Solid engineering. The output just wasn't compelling. Pixel art demands either perfect algorithmic control or genuine artistic AI — we had neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; "It works" ≠ "it's worth shipping." I killed both versions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bilingual Architecture — A Two-Layer Approach
&lt;/h2&gt;

&lt;p&gt;After 60+ tools, I needed bilingual support (English + Chinese). The challenge: no framework, no build step, 87 static HTML files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution: two-layer i18n without a framework.&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;Shared UI&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&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page-specific&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[tool].js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hero, How It Works, Why Use, Pro Tips, FAQ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homepage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;i18n.js&lt;/code&gt; + &lt;code&gt;zh.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tool card titles, stats, search&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&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;// HTML: &amp;lt;span data-i18n="heroTitle"&amp;gt;Compress PDF Files&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// JS:   heroTitle: { en: 'Compress PDF Files', zh: '压缩 PDF 文件' }&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyI18n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&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;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;[data-i18n]&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="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&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;data-i18n&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;dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;I18N&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lang&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;dict&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;dict&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="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;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INPUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;placeholder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dict&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="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;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;el&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;dict&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="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;el&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;dict&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="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;window.switchLang(lang)&lt;/code&gt; triggers both layers simultaneously. Language preference persists via &lt;code&gt;localStorage&lt;/code&gt; and syncs across pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotchas I hit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data-i18n&lt;/code&gt; on a parent with SVG children = disaster.&lt;/strong&gt; &lt;code&gt;innerHTML&lt;/code&gt; assignment wipes the SVG. Always put &lt;code&gt;data-i18n&lt;/code&gt; on a sibling &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, never on a structural parent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unescaped single quotes in i18n dictionaries&lt;/strong&gt; silently break &lt;code&gt;window.switchLang&lt;/code&gt;. One &lt;code&gt;there's&lt;/code&gt; without a backslash killed the entire language system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch &lt;code&gt;data-i18n&lt;/code&gt; injection scripts&lt;/strong&gt; silently fail when HTML entities (&lt;code&gt;&amp;amp;mdash;&lt;/code&gt;) don't match Unicode characters (&lt;code&gt;—&lt;/code&gt;). Always verify with a key checklist after migration.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The 100-Day Celebration — A Small Experiment
&lt;/h2&gt;

&lt;p&gt;For day 100, I wanted to do something fun. I built a full-screen fireworks animation using Canvas API that would automatically appear on the homepage for 24 hours.&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;launchFirework&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;colors&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;#6366f1&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;#a855f7&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;#f59e0b&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;#ef4444&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;#22c55e&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;#3b82f6&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;#ec4899&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;#fff&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;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;colors&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;floor&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;colors&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&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;floor&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;40&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;count&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;angle&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&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;count&lt;/span&gt; &lt;span class="o"&gt;+&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;particles&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="na"&gt;x&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="na"&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="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;vx&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;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;vy&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;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;life&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="na"&gt;decay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.008&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;*&lt;/span&gt; &lt;span class="mf"&gt;0.012&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.5&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;*&lt;/span&gt; &lt;span class="mi"&gt;2&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 fireworks canvas uses &lt;code&gt;clearRect&lt;/code&gt; (not a semi-transparent fill) so particles render crisply against the overlay. An &lt;code&gt;IntersectionObserver&lt;/code&gt; starts/stops the animation when the changelog entry scrolls into view — no wasted CPU when off-screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance note:&lt;/strong&gt; The entire celebration code — CSS, HTML, JS, fireworks — is inlined in &lt;code&gt;index.html&lt;/code&gt;. After day 101, the IIFE returns immediately on the first line. Zero overhead for the remaining lifespan of the site.&lt;/p&gt;

&lt;p&gt;I don't know how many people saw it. That's part of the fun.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Desktop Client — Same Tools, Offline
&lt;/h2&gt;

&lt;p&gt;Now I'm building a Windows desktop app using &lt;strong&gt;Tauri 2 + Rust&lt;/strong&gt;. Same principle: all processing happens locally. The Rust backend handles PDF operations via &lt;code&gt;lopdf&lt;/code&gt; (split/merge) and &lt;code&gt;pdfium&lt;/code&gt; (render to images), while the UI reuses the Vanilla JS frontend.&lt;/p&gt;

&lt;p&gt;The web version isn't going anywhere. The desktop client is for users who want a native app experience — drag-and-drop without a browser tab, offline-first, system-level file access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Numbers After 100 Days
&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;Tools shipped&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools killed after shipping&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bilingual pages migrated&lt;/td&gt;
&lt;td&gt;19 of 87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog guides published&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service Worker cache version&lt;/td&gt;
&lt;td&gt;v144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total uses&lt;/td&gt;
&lt;td&gt;3,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-side code&lt;/td&gt;
&lt;td&gt;2 PHP files (stats only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework dependencies&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I'd Tell Someone Starting a Similar Project
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Static HTML scales further than you think.&lt;/strong&gt; 87 pages with no framework is manageable if you're disciplined about shared CSS/JS and write verification scripts for batch changes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The browser can do more than you expect.&lt;/strong&gt; PDF manipulation, audio synthesis, video transcoding, image processing — all viable client-side. The main limitation isn't capability; it's download size for WASM libraries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kill features that "almost work."&lt;/strong&gt; Live Photo Frame taught me that three near-fatal flaws compound, they don't cancel out. A tool that's 90% right erodes more trust than a missing tool page.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Inline celebration code and clean up after yourself.&lt;/strong&gt; The fireworks animation was fun to build, but the real engineering was making sure it costs zero bytes of overhead after day 101.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;data-i18n&lt;/code&gt; on SVG parents will haunt you.&lt;/strong&gt; Test language toggles on every page, every time.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;The remaining 68 legacy pages will migrate to the bilingual architecture gradually. The desktop client is in active development. New tools are still shipping — the 87th tool (Text Animation Maker with real MP4 export via WebCodecs) went live on June 23.&lt;/p&gt;

&lt;p&gt;If you're curious about the full build history, the &lt;a href="https://toolknit.com/changelog.html" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; is more honest than most — it includes the features that died, the bugs that took hours to find, and the occasional developer's journal entry that has nothing to do with code.&lt;/p&gt;

&lt;p&gt;No signup. No uploads. No tracking beyond a usage counter. Just tools that work in your browser because that's where your files already are.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're building something client-side, I'd love to hear about your architecture decisions. What did you try that didn't work? What surprised you about the browser's capabilities? Drop a comment — I read all of them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>performance</category>
    </item>
    <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>
  </channel>
</rss>
