<?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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3855987%2Fe0a823ea-e62d-4da0-a300-9605ada3319f.png</url>
      <title>DEV Community: Zihang Dong 董子航</title>
      <link>https://dev.to/dngzihng114379</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dngzihng114379"/>
    <language>en</language>
    <item>
      <title>I Built a Browser-Based Pixel Art Converter Using Canvas, Median Cut, and Floyd-Steinberg Dithering</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sun, 12 Apr 2026 03:59:27 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-built-a-browser-based-pixel-art-converter-using-canvas-median-cut-and-floyd-steinberg-dithering-12o4</link>
      <guid>https://dev.to/dngzihng114379/i-built-a-browser-based-pixel-art-converter-using-canvas-median-cut-and-floyd-steinberg-dithering-12o4</guid>
      <description>&lt;p&gt;Pixel art is everywhere — indie games, NFT avatars, social media profiles, retro-themed designs. But there's a huge gap between "pixelating an image" (which looks terrible) and actual pixel art (which looks intentional and beautiful).&lt;/p&gt;

&lt;p&gt;I built a &lt;a href="https://toolknit.com/tools/pixel-art-converter.html" rel="noopener noreferrer"&gt;free Pixel Art Converter&lt;/a&gt; that bridges this gap using 5 classic computer science algorithms, all running in the browser via the Canvas API. No server uploads, no AI — just math.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  The Problem: Pixelation ≠ Pixel Art
&lt;/h2&gt;

&lt;p&gt;If you just shrink an image and scale it back up, you get a blurry mosaic. Real pixel art has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Limited color palettes&lt;/strong&gt; (4–64 colors)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dithering patterns&lt;/strong&gt; that simulate smooth gradients&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark outlines&lt;/strong&gt; around shapes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vibrant, saturated colors&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My converter applies all four of these automatically. Let me walk through each algorithm.&lt;/p&gt;




&lt;h2&gt;
  
  
  Algorithm 1: Downsampling
&lt;/h2&gt;

&lt;p&gt;The simplest step. I draw the source image onto a tiny canvas (e.g., 64px wide), then scale it back up using &lt;code&gt;imageSmoothingEnabled = false&lt;/code&gt; to preserve hard pixel edges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Draw source onto tiny canvas&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;smallCanvas&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;smallCanvas&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;pixelRes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g., 64&lt;/span&gt;
&lt;span class="nx"&gt;smallCanvas&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="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;img&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pixelRes&lt;/span&gt; &lt;span class="o"&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;width&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;smallCanvas&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="nf"&gt;drawImage&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="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;smallCanvas&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;smallCanvas&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="c1"&gt;// Scale back up with nearest-neighbor interpolation&lt;/span&gt;
&lt;span class="nx"&gt;outputCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageSmoothingEnabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;outputCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;smallCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolution recommendations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;16–32px&lt;/strong&gt; → Game sprites, tiny icons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;64px&lt;/strong&gt; → Sweet spot for most images&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;128–256px&lt;/strong&gt; → Detailed portraits, wallpapers&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Algorithm 2: Median Cut Color Quantization
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. Real pixel art uses limited palettes. I use the &lt;strong&gt;median cut algorithm&lt;/strong&gt; to intelligently reduce colors.&lt;/p&gt;

&lt;p&gt;The idea: treat every pixel as a point in 3D RGB space. Recursively split the color space into buckets by finding the channel (R, G, or B) with the widest range and splitting at the median.&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;medianCut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;numColors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buckets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buckets&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="nx"&gt;numColors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Find the bucket with the widest color range&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;targetBucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findWidestBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Find which channel (R, G, B) has the widest range&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findWidestChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetBucket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Sort by that channel and split at the median&lt;/span&gt;
        &lt;span class="nx"&gt;targetBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&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="o"&gt;=&amp;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;channel&lt;/span&gt;&lt;span class="p"&gt;]&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="nx"&gt;channel&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;mid&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="nx"&gt;targetBucket&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;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nx"&gt;buckets&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;targetBucket&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nx"&gt;buckets&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;targetBucket&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="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Each bucket's average color becomes one palette entry&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;averageColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&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;After building the palette, each pixel gets mapped to its closest palette color using Euclidean distance in RGB space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fewer colors = more retro:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;4 colors → Game Boy style&lt;/li&gt;
&lt;li&gt;16 colors → NES/CGA era&lt;/li&gt;
&lt;li&gt;32–64 colors → Best balance for most photos&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Algorithm 3: Floyd-Steinberg Dithering
&lt;/h2&gt;

&lt;p&gt;This is what separates a real pixel art converter from a cheap pixelator. &lt;strong&gt;Dithering&lt;/strong&gt; distributes quantization error to neighboring pixels, creating the illusion of more colors through dot patterns.&lt;/p&gt;

&lt;p&gt;Without dithering: harsh flat color blocks (the "mosaic" look).&lt;br&gt;
With dithering: smooth gradients made of alternating dots — exactly like 8-bit and 16-bit era graphics.&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;floydSteinbergDither&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;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="nx"&gt;width&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;oldR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;oldG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&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;oldB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

            &lt;span class="c1"&gt;// Find nearest palette color&lt;/span&gt;
            &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newB&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findClosest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newR&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newG&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="c1"&gt;// Calculate error&lt;/span&gt;
            &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;errR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oldR&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;newR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oldG&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;newG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oldB&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;newB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="c1"&gt;// Distribute error to neighbors&lt;/span&gt;
            &lt;span class="nf"&gt;distributeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&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;y&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;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;distributeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&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;y&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;width&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;errR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;distributeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&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;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;distributeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&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;y&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;width&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;errR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errB&lt;/span&gt;&lt;span class="p"&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;16&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;The 7/16, 3/16, 5/16, 1/16 distribution creates a natural, organic pattern that avoids visual banding.&lt;/p&gt;




&lt;h2&gt;
  
  
  Algorithm 4: Sobel Edge Detection
&lt;/h2&gt;

&lt;p&gt;Hand-drawn pixel art almost always has dark outlines. I use the &lt;strong&gt;Sobel operator&lt;/strong&gt; to automatically detect and darken edges.&lt;/p&gt;

&lt;p&gt;The Sobel operator uses two 3×3 convolution kernels to calculate horizontal and vertical gradients:&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;sobelX&lt;/span&gt; &lt;span class="o"&gt;=&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="mi"&gt;0&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="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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="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="mi"&gt;0&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sobelY&lt;/span&gt; &lt;span class="o"&gt;=&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;sobelEdgeDetect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Convert to grayscale first&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;height&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;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;width&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;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;gx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;convolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sobelX&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;gy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;convolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sobelY&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;magnitude&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;gx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;gy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;gy&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;magnitude&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Darken this pixel proportionally&lt;/span&gt;
                &lt;span class="nf"&gt;darkenPixel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;magnitude&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where the gradient magnitude exceeds a threshold, pixels get darkened — creating outlines that naturally follow contours.&lt;/p&gt;




&lt;h2&gt;
  
  
  Algorithm 5: HSL Saturation Boost
&lt;/h2&gt;

&lt;p&gt;Pixel art uses bold, vibrant colors. Before quantization, I convert each pixel to HSL, boost saturation (default 130%), and convert back to RGB.&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;boostSaturation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rgbToHsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;s&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;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Boost but clamp at 1.0&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;hslToRgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&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;150–200% saturation gives that vibrant retro game aesthetic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Retro Console Presets
&lt;/h2&gt;

&lt;p&gt;For instant results, I built presets that emulate real gaming hardware:&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;Colors&lt;/th&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;th&gt;Special&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Game Boy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4 green shades&lt;/td&gt;
&lt;td&gt;48px&lt;/td&gt;
&lt;td&gt;Hardcoded 1989 palette&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NES&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;54 colors&lt;/td&gt;
&lt;td&gt;64px&lt;/td&gt;
&lt;td&gt;Authentic NES color table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SNES&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;128 colors&lt;/td&gt;
&lt;td&gt;128px&lt;/td&gt;
&lt;td&gt;Median cut quantization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CGA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16 colors&lt;/td&gt;
&lt;td&gt;64px&lt;/td&gt;
&lt;td&gt;IBM PC palette&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Game Boy and NES presets use &lt;strong&gt;exact hardware palettes&lt;/strong&gt; rather than median cut — every color matches what the original console could display.&lt;/p&gt;




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

&lt;p&gt;Here's the full processing order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source Image
  → Saturation boost (HSL)
  → Downsample to pixel grid (Canvas)
  → Median cut quantization (or preset palette)
  → Floyd-Steinberg dithering (optional)
  → Sobel edge detection + outline darkening (optional)
  → Scale up with nearest-neighbor
  → Output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything runs on a single &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element using &lt;code&gt;getImageData()&lt;/code&gt; and &lt;code&gt;putImageData()&lt;/code&gt;. No WebGL, no Web Workers, no external libraries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Privacy: Zero Uploads
&lt;/h2&gt;

&lt;p&gt;The entire tool runs in the browser. Your image never touches a server. Close the tab and all data is gone. This is possible because the Canvas API gives us direct pixel-level access — we don't need any server-side image processing.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://toolknit.com/tools/pixel-art-converter.html" rel="noopener noreferrer"&gt;→ Pixel Art Converter — Free, No Sign-up&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drop any image, adjust resolution and colors, toggle dithering and outlines, try the Game Boy preset — and download your pixel art as PNG.&lt;/p&gt;

&lt;p&gt;Built as part of &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — 33+ free browser-based tools for PDF, image, video, audio, and more.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What algorithms would you add? I'm considering palette import (load a .pal file) and animated GIF pixel art conversion. Let me know in the comments!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Browser-Based Keyboard Tester with Vanilla JavaScript</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sat, 11 Apr 2026 07:45:30 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/building-a-browser-based-keyboard-tester-with-vanilla-javascript-96g</link>
      <guid>https://dev.to/dngzihng114379/building-a-browser-based-keyboard-tester-with-vanilla-javascript-96g</guid>
      <description>&lt;p&gt;Have you ever bought a new mechanical keyboard only to discover a dead switch two weeks later — past the return window? Or spilled coffee on your laptop and wondered which keys survived?&lt;/p&gt;

&lt;p&gt;I built a &lt;a href="https://toolknit.com/tools/keyboard-tester.html" rel="noopener noreferrer"&gt;free keyboard tester&lt;/a&gt; that runs entirely in the browser. No downloads, no backend, no data collection. Just open the page, press keys, and see which ones work.&lt;/p&gt;

&lt;p&gt;In this post, I'll walk through the key technical decisions and JavaScript patterns I used to build it.&lt;/p&gt;

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

&lt;p&gt;Display a virtual keyboard on screen. When the user presses a physical key, the corresponding virtual key lights up. Simple concept, but there are a few tricky parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mapping physical keys to virtual keys&lt;/strong&gt; — Not all keyboards have the same layout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capturing ALL key presses&lt;/strong&gt; — Including Tab, F-keys, and other keys browsers like to hijack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counting keystrokes&lt;/strong&gt; — For detecting chattering (double-firing keys)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;N-Key Rollover testing&lt;/strong&gt; — Tracking multiple simultaneous key holds&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Key-to-Element Mapping
&lt;/h2&gt;

&lt;p&gt;Each virtual key in the HTML has a &lt;code&gt;data-key&lt;/code&gt; attribute matching the &lt;code&gt;KeyboardEvent.code&lt;/code&gt; value:&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;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"key"&lt;/span&gt; &lt;span class="na"&gt;data-key=&lt;/span&gt;&lt;span class="s"&gt;"KeyA"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;A&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"key"&lt;/span&gt; &lt;span class="na"&gt;data-key=&lt;/span&gt;&lt;span class="s"&gt;"Space"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Space&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"key"&lt;/span&gt; &lt;span class="na"&gt;data-key=&lt;/span&gt;&lt;span class="s"&gt;"ShiftLeft"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Shift&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose &lt;code&gt;event.code&lt;/code&gt; over &lt;code&gt;event.key&lt;/code&gt; because &lt;code&gt;code&lt;/code&gt; represents the &lt;strong&gt;physical key position&lt;/strong&gt;, not the character it produces. This means the tester works regardless of keyboard language or layout (QWERTY, AZERTY, Dvorak, etc).&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;getKeyElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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="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;.key[data-key="&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"]&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;
  
  
  Capturing Every Key Press
&lt;/h2&gt;

&lt;p&gt;Browsers intercept certain keys by default: Tab switches focus, F5 refreshes, F11 toggles fullscreen. To capture these, I use &lt;code&gt;preventDefault()&lt;/code&gt; on both keydown and keyup events, with &lt;code&gt;useCapture: true&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="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="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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopPropagation&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;code&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;code&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;keyEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKeyElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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;keyEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;keyEl&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;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// Visual press feedback&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;testedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;testedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;keyEl&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;tested&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Permanent "tested" state&lt;/span&gt;
            &lt;span class="nf"&gt;updateStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// useCapture = true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the corresponding keyup handler to remove the "active" visual state:&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="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;keyup&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="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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopPropagation&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;keyEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKeyElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="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;keyEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;keyEl&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;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Preventing Browser Shortcuts
&lt;/h3&gt;

&lt;p&gt;Some keys need extra prevention at the window level:&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="nb"&gt;window&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;Tab&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;F1&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;F5&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;F11&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;F12&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;This catches Tab (focus switching), F1 (help), F5 (reload), and F11/F12 (fullscreen/devtools) before the browser processes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Keystroke Counter
&lt;/h2&gt;

&lt;p&gt;This was a recent addition and probably the most useful feature for diagnosing keyboard problems. Mechanical keyboards can develop "chattering" — where one press registers as two or more inputs. This is caused by worn switch contacts bouncing.&lt;/p&gt;

&lt;p&gt;The counter tracks every key press individually:&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;keyCounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalKeystrokes&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="c1"&gt;// Inside keydown handler:&lt;/span&gt;
&lt;span class="nx"&gt;keyCounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&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;keyCounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;totalKeystrokes&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;updateKeystrokeUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI dynamically creates a card for each key, showing its press count. If a key registers 3+ presses, it's highlighted in amber as a potential chattering issue:&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;updateKeystrokeUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;entry&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kc-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;code&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;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;entry&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;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kc-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex items-center justify-between ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&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;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keyCounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&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;isHigh&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;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;entry&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="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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isHigh&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-amber-400&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;text-slate-300&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;"&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;label&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;&amp;lt;span&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;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isHigh&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="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;&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reset function clears all counts without affecting the main keyboard test state:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resetKeystrokeCounter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;keyCounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;keyCounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;totalKeystrokes&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="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;keystroke-entries&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;
  
  
  Progress Tracking
&lt;/h2&gt;

&lt;p&gt;Users need to know how many keys they've tested out of the total. The stats panel shows tested/total/percentage and color-codes the progress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateStats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;testedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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;pct&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;tested&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalKeysCount&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="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;keys-pressed&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;tested&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;keys-percent&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;pct&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="c1"&gt;// Color feedback&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pctEl&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keys-percent&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;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nx"&gt;pctEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;... text-emerald-400&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Green = done&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;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nx"&gt;pctEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;... text-yellow-400&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Yellow = halfway&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;
  
  
  Virtual Key Clicks
&lt;/h2&gt;

&lt;p&gt;Not everyone has a physical keyboard handy (maybe they're testing remotely). Virtual keys are clickable too:&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;allKeys&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;keyEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;keyEl&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="kd"&gt;function&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&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-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;this&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;active&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;testedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;testedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;this&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;tested&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;updateStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Keystroke counter for clicks too&lt;/span&gt;
        &lt;span class="nx"&gt;keyCounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&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;keyCounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;totalKeystrokes&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&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;active&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;event.code&lt;/code&gt; &amp;gt; &lt;code&gt;event.key&lt;/code&gt;&lt;/strong&gt; — Always use &lt;code&gt;code&lt;/code&gt; for physical key identification. &lt;code&gt;key&lt;/code&gt; changes with language/layout.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;useCapture: true&lt;/code&gt; is essential&lt;/strong&gt; — Without it, some keydown events get swallowed before your handler runs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A Set is perfect for tracking tested keys&lt;/strong&gt; — O(1) lookups, no duplicates, and &lt;code&gt;.size&lt;/code&gt; gives you the count for free.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chattering detection needs a counter, not a boolean&lt;/strong&gt; — Just knowing a key "was pressed" isn't enough. You need to know &lt;em&gt;how many times&lt;/em&gt; it registered per intended press.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No framework needed&lt;/strong&gt; — The entire tool is vanilla HTML + CSS + JS. It loads instantly and works offline. Sometimes the simplest stack is the best stack.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The keyboard tester is live at &lt;a href="https://toolknit.com/tools/keyboard-tester.html" rel="noopener noreferrer"&gt;toolknit.com/tools/keyboard-tester.html&lt;/a&gt;. It's part of &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt;, a collection of 30+ free browser-based tools for files, images, video, and more.&lt;/p&gt;

&lt;p&gt;If you have a keyboard collecting dust, plug it in and test it. You might be surprised what you find.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you built similar browser-based utilities? I'd love to hear about the edge cases you ran into. Drop a comment below!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Analyzed 1,000 Professional Emails — Here Are the 5 Patterns That Get Replies</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Thu, 09 Apr 2026 08:25:15 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-analyzed-1000-professional-emails-here-are-the-5-patterns-that-get-replies-32m8</link>
      <guid>https://dev.to/dngzihng114379/i-analyzed-1000-professional-emails-here-are-the-5-patterns-that-get-replies-32m8</guid>
      <description>&lt;p&gt;Last year I built &lt;a href="https://rewriteemail.com" rel="noopener noreferrer"&gt;RewriteEmail&lt;/a&gt;, a free AI tool that rewrites professional emails. After processing thousands of email rewrites, clear patterns emerged about &lt;strong&gt;why some emails get responses and others get ignored&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This isn't theory — it's data from real users rewriting real emails. Here's what I found.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The First Sentence Determines Everything
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; Emails that opened with context ("Following up on our Tuesday call about the Q3 budget") had a dramatically different tone than emails that opened with filler ("I hope this email finds you well").&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Delete your first sentence. Whatever your second sentence is — that's your real opener. We noticed that the AI almost always removed or restructured the first line of user drafts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ "I hope you're doing well. I wanted to reach out regarding..."
✅ "Following up on your question about the API migration timeline —"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The best first sentence answers: &lt;strong&gt;"Why am I reading this right now?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One Ask Per Email — Always
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; Emails with 3+ requests had a noticeably confused, rambling quality. Users would paste in emails asking for a meeting AND feedback AND approval AND a timeline update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; One email = one action item. If you need three things, either prioritize ruthlessly or send three short emails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ "Could you review the deck, send me the budget numbers, 
    and confirm if Thursday works for the all-hands?"

✅ "Could you confirm if Thursday 2pm works for the all-hands? 
    I'll send the deck and budget questions separately."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I call this the &lt;strong&gt;"one reply, one action"&lt;/strong&gt; rule. The recipient should be able to respond in under 60 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The "Apology Spiral" Kills Your Credibility
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; A huge percentage of workplace emails we processed contained over-apologizing. Phrases like "Sorry to bother you," "I'm sorry if this is a stupid question," "Apologies for the delay" (when the delay was 4 hours).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data:&lt;/strong&gt; The AI consistently removed 60-80% of apologies from user drafts and replaced them with direct, confident language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ "Sorry to bother you, but I was wondering if maybe 
    you could possibly share the report when you get a chance?"

✅ "Could you share the Q3 report by Thursday? 
    I need it for the client presentation Friday morning."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Research backs this up — a &lt;a href="https://onlinelibrary.wiley.com/journal/15719979" rel="noopener noreferrer"&gt;2016 study&lt;/a&gt; found that apologies lose perceived sincerity with each repetition. One "sorry" registers as genuine. Four sounds like panic.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Specificity Is the Shortcut to Trust
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; Vague emails ("Let's connect sometime") performed terribly compared to specific ones ("Could we do 15 minutes Thursday at 2pm to review the API spec?").&lt;/p&gt;

&lt;p&gt;This applied everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cold emails&lt;/strong&gt; — "We help companies grow" vs. "We helped [Company X] reduce churn by 23%"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow-ups&lt;/strong&gt; — "Just checking in" vs. "Following up on the proposal I sent Tuesday — any questions about the pricing in Section 3?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introductions&lt;/strong&gt; — "I'd love to connect" vs. "I noticed your talk at ReactConf — your approach to state management mirrors a problem we just solved at [Company]"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; If you can add a number, a date, a name, or a specific detail — do it. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. End with a Binary Question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; Emails that ended with open-ended questions ("What do you think?" / "Let me know your thoughts") had weaker closing structures than emails ending with yes/no questions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ "Let me know your thoughts on the proposal."
✅ "Does the $12,000 budget work, or should I scope a smaller pilot first?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Binary questions get faster replies because they require less cognitive effort. The reader doesn't need to formulate an opinion — they just pick A or B.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Meta-Lesson: Tone &amp;gt; Content
&lt;/h2&gt;

&lt;p&gt;The biggest surprise from building this tool wasn't about &lt;strong&gt;what&lt;/strong&gt; people wrote — it was about &lt;strong&gt;how&lt;/strong&gt; they wrote it. The same message, restructured and re-toned, became a completely different email.&lt;/p&gt;

&lt;p&gt;Most people know what they want to say. The problem is &lt;em&gt;how&lt;/em&gt; they say it. That's the gap AI fills surprisingly well — not generating content from scratch, but &lt;strong&gt;reshaping your intent into professional, clear communication&lt;/strong&gt;.&lt;/p&gt;




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

&lt;p&gt;If you're curious, &lt;a href="https://rewriteemail.com" rel="noopener noreferrer"&gt;RewriteEmail.com&lt;/a&gt; is free to try — paste any email draft and see how AI restructures it. No sign-up required for the first rewrite.&lt;/p&gt;

&lt;p&gt;The tool uses the patterns above (and many more) to transform drafts in about 30 seconds. It's been particularly useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Non-native English speakers writing professional emails&lt;/li&gt;
&lt;li&gt;Developers who need to communicate with non-technical stakeholders&lt;/li&gt;
&lt;li&gt;Anyone who's ever stared at a draft for 20 minutes wondering "does this sound right?"&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;What email-writing patterns have you noticed in your own work? I'd love to hear in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>writing</category>
      <category>career</category>
      <category>ai</category>
    </item>
    <item>
      <title>How I Built a Free QR Code Generator That Runs 100% in Your Browser</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Thu, 09 Apr 2026 07:15:20 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/how-i-built-a-free-qr-code-generator-that-runs-100-in-your-browserpublished-true-2aim</link>
      <guid>https://dev.to/dngzihng114379/how-i-built-a-free-qr-code-generator-that-runs-100-in-your-browserpublished-true-2aim</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy1iqyrc64yo886asv37s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy1iqyrc64yo886asv37s.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Last month I launched &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a collection of 31 free browser-based tools. One of the latest additions is a &lt;strong&gt;QR Code Generator&lt;/strong&gt; that supports URLs, text, Wi-Fi credentials, and email — all processed locally with zero server calls.&lt;/p&gt;

&lt;p&gt;In this post, I'll walk through how I built it and share some interesting technical decisions along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Another QR Code Generator?
&lt;/h2&gt;

&lt;p&gt;Most QR code generators online either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Require you to sign up&lt;/li&gt;
&lt;li&gt;Add watermarks to the output&lt;/li&gt;
&lt;li&gt;Upload your data to their servers&lt;/li&gt;
&lt;li&gt;Limit the number of QR codes you can create&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted something that's &lt;strong&gt;completely free, private, and unlimited&lt;/strong&gt;. Since everything runs in the browser using JavaScript, your data never leaves your device.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://toolknit.com/tools/qr-code-generator.html" rel="noopener noreferrer"&gt;Try it here: ToolKnit QR Code Generator&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;The tool is surprisingly simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTML + Tailwind CSS&lt;/strong&gt; for the UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;qrcode.js&lt;/strong&gt; library for QR code generation (via CDN)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas API&lt;/strong&gt; for rendering and downloading&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No React, no build tools, no backend. Just a single HTML file that works everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How QR Code Generation Works
&lt;/h2&gt;

&lt;p&gt;QR codes encode data as a matrix of black and white squares. The &lt;code&gt;qrcode.js&lt;/code&gt; library handles the heavy lifting:&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;qr&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;QRCode&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;qr-output&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;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inputText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;colorDark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;colorLight&lt;/span&gt;&lt;span class="p"&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="na"&gt;correctLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CorrectLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;M&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's literally it for basic generation. But I wanted to support multiple content types, so I added a tab system for different QR code formats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supporting Wi-Fi QR Codes
&lt;/h2&gt;

&lt;p&gt;This was the most interesting part. Did you know your phone can auto-connect to Wi-Fi by scanning a QR code? The format is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WIFI:T:WPA;S:MyNetwork;P:MyPassword;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;T&lt;/code&gt; = Authentication type (WPA, WEP, or nopass)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;S&lt;/code&gt; = SSID (network name)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;P&lt;/code&gt; = Password&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my implementation:&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;buildWifiString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ssid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encryption&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="s2"&gt;`WIFI:T:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;encryption&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;S:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ssid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;P:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;password&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Super useful for cafes, offices, or events where you want guests to connect easily without typing long passwords.&lt;/p&gt;

&lt;h2&gt;
  
  
  Email QR Codes
&lt;/h2&gt;

&lt;p&gt;Similarly, you can encode a &lt;code&gt;mailto:&lt;/code&gt; link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;mailto&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt;hello@example.com?subject=Hello&amp;amp;body=Hi there&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When scanned, it opens the email app with pre-filled fields. Great for business cards or event badges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error Correction Levels
&lt;/h2&gt;

&lt;p&gt;One thing I learned building this: QR codes have &lt;strong&gt;4 error correction levels&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;Level&lt;/th&gt;
&lt;th&gt;Recovery&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L (Low)&lt;/td&gt;
&lt;td&gt;~7%&lt;/td&gt;
&lt;td&gt;Clean digital screens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M (Medium)&lt;/td&gt;
&lt;td&gt;~15%&lt;/td&gt;
&lt;td&gt;General use (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q (Quartile)&lt;/td&gt;
&lt;td&gt;~25%&lt;/td&gt;
&lt;td&gt;Printed materials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H (High)&lt;/td&gt;
&lt;td&gt;~30%&lt;/td&gt;
&lt;td&gt;Logos overlay, harsh conditions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Higher error correction means the QR code still works even if part of it is damaged or obscured. I defaulted to &lt;strong&gt;M (Medium)&lt;/strong&gt; as a good balance between data density and reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Colors
&lt;/h2&gt;

&lt;p&gt;I added color pickers for foreground and background colors:&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;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;colorDark&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;fg-color&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;colorLight&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;bg-color&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: &lt;strong&gt;contrast matters&lt;/strong&gt;. If someone picks similar colors for foreground and background, the QR code becomes unreadable. I considered adding a contrast check but decided to trust the user — most people intuitively pick high-contrast combinations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download as PNG
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;qrcode.js&lt;/code&gt; library renders to a canvas element, so downloading is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;downloadQR&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;#qr-output canvas&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;link&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;link&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;qrcode.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;link&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;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&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="nx"&gt;link&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;No server round-trip, no temporary file storage. The PNG is generated entirely in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO Considerations
&lt;/h2&gt;

&lt;p&gt;Since &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; is a tool site that relies on organic search traffic, I spent time on SEO:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Schema.org structured data&lt;/strong&gt; — &lt;code&gt;SoftwareApplication&lt;/code&gt; and &lt;code&gt;FAQPage&lt;/code&gt; markup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 FAQ questions&lt;/strong&gt; with answers in JSON-LD&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic HTML&lt;/strong&gt; — proper heading hierarchy, descriptive alt text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; — single-page tool with minimal dependencies loads fast&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For tool sites, I've found that &lt;strong&gt;FAQ structured data&lt;/strong&gt; is especially powerful. Google often shows FAQ rich snippets directly in search results, which dramatically improves click-through rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Improve
&lt;/h2&gt;

&lt;p&gt;If I revisit this tool, I'd add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SVG export&lt;/strong&gt; for vector quality&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logo overlay&lt;/strong&gt; in the center of the QR code (using H error correction)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch generation&lt;/strong&gt; for creating multiple QR codes at once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vCard format&lt;/strong&gt; for contact information&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The QR Code Generator is live at &lt;a href="https://toolknit.com/tools/qr-code-generator.html" rel="noopener noreferrer"&gt;toolknit.com/tools/qr-code-generator.html&lt;/a&gt;. It's one of 31 free tools on &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — all browser-based, no signup required.&lt;/p&gt;

&lt;p&gt;Other popular tools on the site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/character-counter.html" rel="noopener noreferrer"&gt;Character Counter&lt;/a&gt; — count characters, words &amp;amp; paragraphs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/compress-image.html" rel="noopener noreferrer"&gt;Image Compressor&lt;/a&gt; — compress images without quality loss&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/pdf-to-word.html" rel="noopener noreferrer"&gt;PDF to Word&lt;/a&gt; — convert PDFs to editable documents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building browser-based tools, I'd love to hear about your approach. Drop a comment below! 🚀&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full tool list and source available on &lt;a href="https://github.com/2645149786-dotcom/awesome-free-browser-tools" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built 30+ Free Browser Tools in One Month — Here's What I Learned</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Wed, 08 Apr 2026 09:08:42 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-built-30-free-browser-tools-in-one-month-heres-what-i-learned-kbh</link>
      <guid>https://dev.to/dngzihng114379/i-built-30-free-browser-tools-in-one-month-heres-what-i-learned-kbh</guid>
      <description>&lt;h2&gt;
  
  
  The Frustration That Started It All
&lt;/h2&gt;

&lt;p&gt;It was a late night in March 2026. I needed to compress a PDF. Simple, right?&lt;/p&gt;

&lt;p&gt;Nope. The first site hit me with a 30-second ad. The second wanted me to "download our free software" (spoiler: it wasn't free). The third uploaded my file to god-knows-where. I closed my laptop, stared at the ceiling, and thought:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Why can't someone just build clean, fast tools that work in the browser?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I did. In one month, I built &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — a free, browser-based toolbox with 30+ tools. No uploads. No signups. No ads. Everything runs locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Here's the full toolkit, organized by category:&lt;/p&gt;

&lt;h3&gt;
  
  
  📄 PDF Tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/compress-pdf.html" rel="noopener noreferrer"&gt;Compress PDF&lt;/a&gt; — Shrink PDFs without losing quality&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/merge-pdf.html" rel="noopener noreferrer"&gt;Merge PDF&lt;/a&gt; — Combine multiple PDFs into one&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/pdf-to-word.html" rel="noopener noreferrer"&gt;PDF to Word&lt;/a&gt; / &lt;a href="https://toolknit.com/tools/word-to-pdf.html" rel="noopener noreferrer"&gt;Word to PDF&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/pdf-to-image.html" rel="noopener noreferrer"&gt;PDF to Image&lt;/a&gt; / &lt;a href="https://toolknit.com/tools/image-to-pdf.html" rel="noopener noreferrer"&gt;Image to PDF&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🖼️ Image Tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/compress-image.html" rel="noopener noreferrer"&gt;Compress Image&lt;/a&gt; — Reduce file size in-browser&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/image-crop.html" rel="noopener noreferrer"&gt;Image Crop&lt;/a&gt; — Crop with custom aspect ratios&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/image-grid-split.html" rel="noopener noreferrer"&gt;Image Grid Split&lt;/a&gt; — Split images for Instagram carousels&lt;/li&gt;
&lt;li&gt;Format converters: &lt;a href="https://toolknit.com/tools/jpg-to-png.html" rel="noopener noreferrer"&gt;JPG↔PNG&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/jpg-to-webp.html" rel="noopener noreferrer"&gt;JPG↔WebP&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/png-to-webp.html" rel="noopener noreferrer"&gt;PNG↔WebP&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/webp-to-jpg.html" rel="noopener noreferrer"&gt;WebP→JPG&lt;/a&gt;, &lt;a href="https://toolknit.com/tools/webp-to-png.html" rel="noopener noreferrer"&gt;WebP→PNG&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎬 Video &amp;amp; Audio
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/compress-video.html" rel="noopener noreferrer"&gt;Compress Video&lt;/a&gt; — FFmpeg-powered, runs in browser via WASM&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/video-to-gif.html" rel="noopener noreferrer"&gt;Video to GIF&lt;/a&gt; — Make GIFs from any video&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/mp3-to-wav.html" rel="noopener noreferrer"&gt;MP3↔WAV&lt;/a&gt; converter&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⏱️ Utilities
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/stopwatch.html" rel="noopener noreferrer"&gt;Stopwatch&lt;/a&gt; / &lt;a href="https://toolknit.com/tools/countdown-timer.html" rel="noopener noreferrer"&gt;Countdown Timer&lt;/a&gt; / &lt;a href="https://toolknit.com/tools/world-clock.html" rel="noopener noreferrer"&gt;World Clock&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/character-counter.html" rel="noopener noreferrer"&gt;Character Counter&lt;/a&gt; — With social media limits built in&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/extract-text.html" rel="noopener noreferrer"&gt;Extract Text&lt;/a&gt; — Pull text from PDF, DOCX, XLSX &amp;amp; 12+ formats&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/keyboard-tester.html" rel="noopener noreferrer"&gt;Keyboard Tester&lt;/a&gt; — Test every key on your keyboard&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎲 Fun Stuff
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/what-to-eat.html" rel="noopener noreferrer"&gt;What to Eat?&lt;/a&gt; — A random food picker (yes, really)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/ask-fate.html" rel="noopener noreferrer"&gt;Ask Fate&lt;/a&gt; — A Magic 8-Ball for life decisions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/random-spinner.html" rel="noopener noreferrer"&gt;Spinner Wheel&lt;/a&gt; — Custom random picker&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/reaction-time-test.html" rel="noopener noreferrer"&gt;Reaction Time Test&lt;/a&gt; — How fast are your reflexes?&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://toolknit.com/tools/whiteboard.html" rel="noopener noreferrer"&gt;Drawing Board&lt;/a&gt; — Sketch, doodle &amp;amp; export&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Nothing fancy. Intentionally simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTML + Tailwind CSS&lt;/strong&gt; — Static pages, no framework overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vanilla JavaScript&lt;/strong&gt; — No React, no Vue, just plain JS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FFmpeg.wasm&lt;/strong&gt; — For video compression and GIF conversion in-browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF.js + pdf-lib&lt;/strong&gt; — For PDF manipulation without server uploads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas API&lt;/strong&gt; — For image processing (crop, convert, compress)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers&lt;/strong&gt; — Heavy tasks don't block the UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single operation happens in the user's browser. Files never leave the device. This was a non-negotiable design decision from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Browser APIs are incredibly powerful now
&lt;/h3&gt;

&lt;p&gt;You can compress video, manipulate PDFs, convert image formats, and process audio — all without a server. The gap between "browser tool" and "desktop app" is shrinking fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. SEO is a full-time job
&lt;/h3&gt;

&lt;p&gt;Building the tools was the easy part. Getting people to &lt;em&gt;find&lt;/em&gt; them? That's where the real grind is. I spent as much time on meta tags, Schema.org markup, Open Graph images, sitemaps, and blog posts as I did on actual tool development.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. PageSpeed matters more than you think
&lt;/h3&gt;

&lt;p&gt;I obsessed over Lighthouse scores. Every tool page loads in under 1 second. No heavy frameworks. Lazy-loaded icons. Deferred scripts. The result? Google actually started indexing pages within days.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. "Simple" tools still have edge cases
&lt;/h3&gt;

&lt;p&gt;A "simple" JPG-to-PNG converter sounds trivial until you deal with EXIF orientation, color profiles, transparency handling, and batch downloads. Every tool had its own rabbit hole.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Fun tools drive traffic
&lt;/h3&gt;

&lt;p&gt;My most popular pages? Not the PDF compressor. It's the &lt;a href="https://toolknit.com/tools/reaction-time-test.html" rel="noopener noreferrer"&gt;Reaction Time Test&lt;/a&gt; and &lt;a href="https://toolknit.com/tools/keyboard-tester.html" rel="noopener noreferrer"&gt;Keyboard Tester&lt;/a&gt;. People share fun tools. They bookmark utility tools.&lt;/p&gt;

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

&lt;p&gt;I'm planning to add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background remover (AI-powered, still client-side)&lt;/li&gt;
&lt;li&gt;JSON/CSV formatter&lt;/li&gt;
&lt;li&gt;Color palette generator&lt;/li&gt;
&lt;li&gt;Markdown editor with live preview&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have ideas for tools you wish existed, I'd love to hear them in the comments.&lt;/p&gt;

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

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;toolknit.com&lt;/a&gt;&lt;/strong&gt; — 30+ free browser tools, no signup, no uploads, 100% private.&lt;/p&gt;

&lt;p&gt;If you find it useful, a bookmark or share would mean the world. I'm a solo developer building this after my 9-to-5, and every bit of support keeps me going. ✨&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with ☕ and too many late nights by &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;Mr.Dong&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built a Free AI Sleep Tracker as a PWA — No App Store Needed</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Wed, 01 Apr 2026 16:16:03 +0000</pubDate>
      <link>https://dev.to/dngzihng114379/i-built-a-free-ai-sleep-tracker-as-a-pwa-no-app-store-needed-4i09</link>
      <guid>https://dev.to/dngzihng114379/i-built-a-free-ai-sleep-tracker-as-a-pwa-no-app-store-needed-4i09</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Most sleep tracking apps cost $30-60/year, require app store downloads, and upload your personal sleep data to the cloud. I wanted something simpler.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://oohsleep.com" rel="noopener noreferrer"&gt;OohSleep&lt;/a&gt;&lt;/strong&gt; — a free Progressive Web App that tracks your sleep, detects snoring with AI, and helps you fall asleep with ambient sounds and breathing exercises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🎤 &lt;strong&gt;AI Snoring Detection&lt;/strong&gt; — Uses the Web Audio API and MediaRecorder to listen for snoring patterns during sleep&lt;/li&gt;
&lt;li&gt;🎵 &lt;strong&gt;8 Sleep Sounds&lt;/strong&gt; — Rain, ocean waves, thunder, campfire, wind, forest stream, crickets, and snow — all mixable&lt;/li&gt;
&lt;li&gt;🫁 &lt;strong&gt;5 Breathing Exercises&lt;/strong&gt; — 4-7-8, Box Breathing, Belly Breathing, Alternate Nostril, and Physiological Sigh with guided animations&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;Sleep Reports&lt;/strong&gt; — Track duration, quality score, and sound events over time&lt;/li&gt;
&lt;li&gt;📅 &lt;strong&gt;Sleep Calendar&lt;/strong&gt; — Visual history with daily and weekly views&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;No frameworks, no backend, no database. Just:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vanilla HTML/CSS/JS&lt;/li&gt;
&lt;li&gt;Web Audio API for sound mixing, playback, and audio analysis&lt;/li&gt;
&lt;li&gt;MediaRecorder API for recording sleep audio and detecting snoring&lt;/li&gt;
&lt;li&gt;Service Worker for full offline support&lt;/li&gt;
&lt;li&gt;LocalStorage / IndexedDB for on-device data storage&lt;/li&gt;
&lt;li&gt;PWA Manifest for cross-platform installation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why PWA?
&lt;/h2&gt;

&lt;p&gt;A Progressive Web App was the perfect fit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform&lt;/strong&gt; — Works on iOS, Android, Windows, Mac&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No app store&lt;/strong&gt; — No review process, no 30% cut, instant updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline first&lt;/strong&gt; — Works without internet after first visit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy by design&lt;/strong&gt; — No server means no data collection&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How Snoring Detection Works
&lt;/h2&gt;

&lt;p&gt;The snoring detection uses the Web Audio API AnalyserNode to process microphone input in real-time. It captures audio via getUserMedia, runs FFT analysis to get frequency data, identifies characteristic snoring patterns (low-frequency rhythmic bursts), and uses MediaRecorder to save audio clips when snoring is detected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sound Mixing
&lt;/h2&gt;

&lt;p&gt;Users can layer multiple ambient sounds simultaneously. Each sound has independent volume control through Web Audio API gain nodes, allowing custom sound environments for sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Business Model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free tier&lt;/strong&gt; — Basic sleep tracking, timer, limited sounds, 7-day history&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Premium $14.99/year&lt;/strong&gt; — AI analysis, snoring detection, unlimited sounds, full history&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;p&gt;Open it on your phone, install it to your home screen, and try it tonight.&lt;/p&gt;

&lt;p&gt;What technical challenges have you faced building PWAs? Let me know in the comments!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>pwa</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
