<?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>Forem: Zihang Dong 董子航</title>
    <description>The latest articles on Forem by Zihang Dong 董子航 (@dngzihng114379).</description>
    <link>https://forem.com/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>Forem: Zihang Dong 董子航</title>
      <link>https://forem.com/dngzihng114379</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/dngzihng114379"/>
    <language>en</language>
    <item>
      <title>I Built 58 Free Browser Tools With Zero Backend — Here's the Full Stack</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Mon, 11 May 2026 04:53:17 +0000</pubDate>
      <link>https://forem.com/dngzihng114379/i-built-58-free-browser-tools-with-zero-backend-heres-the-full-stack-435h</link>
      <guid>https://forem.com/dngzihng114379/i-built-58-free-browser-tools-with-zero-backend-heres-the-full-stack-435h</guid>
      <description>&lt;p&gt;Every "free online tool" site I've ever used follows the same playbook: upload your file to their server, wait, download the result — usually after a signup wall, a file-size cap, or a watermark slapped on your output. Your data sits on someone else's machine, and you just have to &lt;em&gt;trust&lt;/em&gt; that they delete it.&lt;/p&gt;

&lt;p&gt;I didn't love that. So I built &lt;a href="https://toolknit.com" rel="noopener noreferrer"&gt;ToolKnit&lt;/a&gt; — &lt;strong&gt;58 free online tools that run entirely in your browser&lt;/strong&gt;. No backend server. No file uploads. No accounts. No database. Just static HTML, CSS, JavaScript, and a pile of WebAssembly.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  The Architecture: There Is No Server
&lt;/h2&gt;

&lt;p&gt;ToolKnit is a static site. Every page is plain HTML served from a single origin. There is no Express server, no Lambda function, no S3 bucket receiving your files. When you compress a PDF on ToolKnit, the compression happens &lt;strong&gt;on your device&lt;/strong&gt;. When you convert a PNG to WebP, your browser's Canvas API does the pixel work. Nothing leaves your machine.&lt;/p&gt;

&lt;p&gt;This isn't a philosophical choice — it's a technical one. Browser APIs in 2026 are powerful enough to handle 90% of what people use online tools for. The remaining 10% (think: server-side AI, heavy video transcoding at scale) doesn't justify compromising on privacy for the other 90%.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://pdf-lib.js.org/" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt; (WebAssembly) — compress, merge, split, convert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Video&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ffmpegwasm.netlify.app/" rel="noopener noreferrer"&gt;FFmpeg.wasm&lt;/a&gt; — compress, convert to GIF, extract audio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Image&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Canvas API + OffscreenCanvas — format conversion, crop, resize, compress, pixel art&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Web Audio API + AudioContext — MP3 ↔ WAV conversion, video audio extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://tesseract.projectnaptha.com/" rel="noopener noreferrer"&gt;Tesseract.js&lt;/a&gt; — extract text from images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Crypto&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Web Crypto API — password generation with true randomness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Export&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Canvas 2D API — pixel-perfect PNG/PDF export for the Daily Planner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Styling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tailwind CSS + Space Grotesk — consistent B&amp;amp;W theme across all 58 tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Offline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Service Worker — full PWA, works without internet after first visit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No React. No Vue. No build step. Every tool is a self-contained HTML page with inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags. Sounds old-school? It is. It also means zero bundle size overhead, instant page loads, and the ability to cache every tool independently via the Service Worker.&lt;/p&gt;




&lt;h2&gt;
  
  
  58 Tools, 8 Categories
&lt;/h2&gt;

&lt;p&gt;Here's what you can do without uploading a single byte to any server:&lt;/p&gt;

&lt;h3&gt;
  
  
  📄 PDF Tools (6)
&lt;/h3&gt;

&lt;p&gt;Compress PDF, Merge PDF, PDF to Image, Image to PDF, PDF to Word, Word to PDF.&lt;/p&gt;

&lt;p&gt;All powered by &lt;code&gt;pdf-lib&lt;/code&gt; running in WebAssembly. The compression works by downsampling embedded images and stripping unused metadata — no quality loss on text, significant size reduction on image-heavy documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  🖼️ Image Tools (12)
&lt;/h3&gt;

&lt;p&gt;Compress, crop, resize, grid split, pixel art converter, flip &amp;amp; rotate, plus six format converters (JPG ↔ PNG ↔ WebP).&lt;/p&gt;

&lt;p&gt;The pixel art converter is one of my favorites. It quantizes colors using median-cut, applies optional dithering (Floyd-Steinberg, ordered, Atkinson), and ships with presets for Game Boy, NES, and SNES palettes. All in ~400 lines of vanilla JS.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎬 Video Tools (3)
&lt;/h3&gt;

&lt;p&gt;Compress Video, Video to GIF, Video to Audio.&lt;/p&gt;

&lt;p&gt;FFmpeg.wasm is a beast. It's a full FFmpeg compiled to WebAssembly — we're talking H.264 encoding/decoding, GIF muxing, and audio extraction, all in a browser tab. The trade-off is a ~25MB WASM download on first use, but it's cached by the Service Worker after that.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Audio Tools (2)
&lt;/h3&gt;

&lt;p&gt;MP3 to WAV, WAV to MP3.&lt;/p&gt;

&lt;p&gt;Web Audio API handles decoding. For MP3 encoding, we use a WASM-based LAME encoder. Bitrate is user-selectable from 128–320 kbps.&lt;/p&gt;

&lt;h3&gt;
  
  
  📝 Text Tools (6)
&lt;/h3&gt;

&lt;p&gt;Character Counter, Lorem Ipsum Generator, Extract Text (OCR), QR Code Generator, Password Generator, Text Diff.&lt;/p&gt;

&lt;p&gt;The Password Generator uses &lt;code&gt;crypto.getRandomValues()&lt;/code&gt; — not &lt;code&gt;Math.random()&lt;/code&gt;. Customizable length (4–128 chars), character type toggles, real-time strength analysis, and bulk generation up to 50 passwords at once.&lt;/p&gt;

&lt;p&gt;Text Diff does line-by-line comparison with additions, deletions, and unchanged lines highlighted — unified or side-by-side view. Built from scratch, no difflib dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧮 Calculator Tools (4)
&lt;/h3&gt;

&lt;p&gt;Age Calculator, Unit Converter, Percentage Calculator, BMI Calculator.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⏱️ Time &amp;amp; Productivity (6)
&lt;/h3&gt;

&lt;p&gt;Stopwatch, Countdown Timer, World Clock, Pomodoro Timer, Invoice Generator, Timestamp Converter, Daily Planner.&lt;/p&gt;

&lt;p&gt;The Daily Planner deserves its own section (see below).&lt;/p&gt;

&lt;h3&gt;
  
  
  🎮 Creative &amp;amp; Fun (13)
&lt;/h3&gt;

&lt;p&gt;Whiteboard, Random Spinner Wheel, What to Eat?, Ask Fate (Magic 8-Ball), Keyboard Tester, Reaction Time Test, Aim Trainer, CPS Test, Coin Flip, Dice Roller, Color Picker, Gradient Generator, Daily Planner.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Canvas 2D Rewrite: Why I Ditched html2canvas
&lt;/h2&gt;

&lt;p&gt;The Daily Planner lets you design a weekly schedule and export it as a pixel-perfect PNG or PDF. Version 1 used &lt;code&gt;html2canvas&lt;/code&gt; for the export. It worked — mostly. But &lt;code&gt;html2canvas&lt;/code&gt; doesn't &lt;em&gt;actually&lt;/em&gt; use your browser's rendering engine. It re-implements CSS in JavaScript, reads your DOM, and paints an approximation onto a canvas.&lt;/p&gt;

&lt;p&gt;The result? Fonts were slightly off. Border-radius clips were wrong. Gradients didn't match. Every CSS property I added required checking whether html2canvas supported it.&lt;/p&gt;

&lt;p&gt;So I ripped it out and wrote raw Canvas 2D API calls:&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;// Instead of html2canvas re-interpreting my CSS...&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0a0a0a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;700 28px "Space Grotesk"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="c1"&gt;// Rounded rectangles for time slots&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;roundRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slotX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slotY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slotW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slotH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(255,255,255,0.04)&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;fill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(255,255,255,0.08)&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;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pixel-perfect. Zero offset. The export now matches the browser preview exactly. Sometimes the simplest solution is the one you should have picked first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Privacy by Architecture
&lt;/h2&gt;

&lt;p&gt;This isn't a privacy &lt;em&gt;policy&lt;/em&gt; — it's a privacy &lt;em&gt;architecture&lt;/em&gt;. There's no server to leak data because there's no server processing data. Your files are read by the browser's File API, processed in local memory, and the output is generated client-side. The only network requests ToolKnit makes are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Google Analytics&lt;/strong&gt; — page views only, no file data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ahrefs Analytics&lt;/strong&gt; — same, page views only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Fonts&lt;/strong&gt; — loading Space Grotesk&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. No telemetry on what files you process, how large they are, or what tools you use. No cookies for tracking individual users. The only local storage is a daily usage counter (500 uses/day fair-use limit) and optional personal bests for gaming tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  Offline-First: It's a PWA
&lt;/h2&gt;

&lt;p&gt;ToolKnit registers a Service Worker that caches all tool pages and core assets on first visit. After that, you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disconnect from the internet&lt;/li&gt;
&lt;li&gt;Open any cached tool&lt;/li&gt;
&lt;li&gt;Process files normally&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On mobile, you can add ToolKnit to your home screen and it launches like a native app — full-screen, no browser chrome, instant load.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned Building This
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Browser APIs are underrated.&lt;/strong&gt; Canvas, Web Audio, File API, Crypto, Service Workers — you can build &lt;em&gt;a lot&lt;/em&gt; before you need a server.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;WebAssembly is production-ready.&lt;/strong&gt; pdf-lib and FFmpeg.wasm handle real workloads. The WASM download is the only downside, and caching solves it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Static sites scale infinitely.&lt;/strong&gt; No server means no server costs, no server crashes, no server maintenance. The only cost is the domain and CDN.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Privacy sells itself.&lt;/strong&gt; I never marketed ToolKnit as a "privacy tool." But when users realize their files never leave their device, they tell other people. Word of mouth from trust is the best marketing channel.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One tool at a time.&lt;/strong&gt; I built and shipped one tool per session, tested it live, then moved on. 58 tools later, the compound effect is real.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Try It
&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; — 58 free tools, zero uploads, works offline.&lt;/p&gt;

&lt;p&gt;If ToolKnit has ever saved you five minutes, or helped you avoid installing yet another sketchy desktop app — &lt;strong&gt;share it with someone&lt;/strong&gt;. Drop it in a Reddit thread. Mention it to a colleague. Every recommendation means the world to a solo developer shipping code at midnight.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://x.com/dngzihng114379" rel="noopener noreferrer"&gt;Zihang Dong&lt;/a&gt;. Follow the journey 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>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I just shipped my 50th free browser tool — here's what building alone at midnight looks like</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Fri, 08 May 2026 16:50:26 +0000</pubDate>
      <link>https://forem.com/dngzihng114379/i-just-shipped-my-50th-free-browser-tool-heres-what-building-alone-at-midnight-looks-like-175m</link>
      <guid>https://forem.com/dngzihng114379/i-just-shipped-my-50th-free-browser-tool-heres-what-building-alone-at-midnight-looks-like-175m</guid>
      <description>&lt;p&gt;It's 12:30 AM. The apartment is quiet. The only light is my monitor, and the terminal cursor blinks like a heartbeat.&lt;/p&gt;

&lt;p&gt;Tonight, ToolKnit crossed 50 tools. Fifty-two, to be exact.&lt;/p&gt;

&lt;p&gt;What is ToolKnit?&lt;br&gt;
ToolKnit is a collection of free, browser-based tools — PDF converters, image editors, video compressors, text utilities, calculators, and more. Every tool runs entirely in your browser. No file uploads, no sign-ups, no server-side processing. Your files never leave your device.&lt;/p&gt;

&lt;p&gt;The stack is deliberately simple: static HTML, Tailwind CSS, vanilla JavaScript. No React, no Next.js, no build step. Just files on a server.&lt;/p&gt;

&lt;p&gt;What shipped tonight&lt;br&gt;
Three new tools and a brand new category:&lt;/p&gt;

&lt;p&gt;Unit Converter — 8 categories (length, weight, temperature, area, volume, speed, data, time), bidirectional conversion with real-time results&lt;br&gt;
Percentage Calculator — three modes: "X% of Y", percentage change, and "X is what % of Y"&lt;br&gt;
Markdown Editor — side-by-side live preview, export to .md, copy rendered HTML, word/char/line stats&lt;br&gt;
These three, along with the existing Age Calculator, formed a new Calculator Tools category — the 8th category on the platform.&lt;/p&gt;

&lt;p&gt;The unglamorous parts&lt;br&gt;
Building solo means you're the developer, the designer, the QA team, the DevOps engineer, and the person who accidentally deploys the wrong file to production at 11 PM.&lt;/p&gt;

&lt;p&gt;The Encoding Incident&lt;br&gt;
I discovered that 46 out of 52 tool pages had garbled characters. Every em-dash had been corrupted into �? — the Unicode replacement character. Somewhere in a batch edit, UTF-8 encoding broke silently across nearly the entire site.&lt;/p&gt;

&lt;p&gt;46 files. One silent bug. Encoding is the villain arc nobody warns you about.&lt;/p&gt;

&lt;p&gt;The Homepage Incident&lt;br&gt;
While deploying fixes, I accidentally SCP'd blog/index.html to the root directory — overwriting the entire homepage with the blog page. The main landing page, just gone.&lt;/p&gt;

&lt;p&gt;Caught it within a minute. Fixed it in two. But that's the kind of mistake that makes your stomach drop when you're the only person between "working website" and "oops."&lt;/p&gt;

&lt;p&gt;By the numbers&lt;br&gt;
Metric  Count&lt;br&gt;
Total tools 52&lt;br&gt;
Blog articles   51&lt;br&gt;
Tool categories 8&lt;br&gt;
Files with encoding bugs fixed tonight  46&lt;br&gt;
Times I accidentally broke production   1&lt;br&gt;
Time to fix it  ~60 seconds&lt;br&gt;
Why build this way?&lt;br&gt;
I get asked why I don't use a framework. Why no React, no component library, no CMS.&lt;/p&gt;

&lt;p&gt;The answer is simple: every tool is a single HTML file. No build step means no build failures. No dependencies means no supply chain risk. No server processing means no server costs beyond a basic VPS. A user opens a page, the tool works. That's it.&lt;/p&gt;

&lt;p&gt;The trade-off is that updates require touching many files manually. Tonight I had to update the tool count in index.html, manifest.json, 404.html, changelog.html, service-worker.js, sitemap.xml, search.js, and the var tools array inside all 52 tool pages. That's the price of simplicity.&lt;/p&gt;

&lt;p&gt;What's next&lt;br&gt;
More tools. Same chair, same dark room, same quiet conviction that free tools should just work.&lt;/p&gt;

&lt;p&gt;If you want to check it out: toolknit.com&lt;/p&gt;

&lt;p&gt;If you're also building something alone at midnight — I see you.&lt;/p&gt;

&lt;p&gt;— Zihang Dong, May 9, 2026&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why AI Image Generators Are Moving From "One Prompt Box" to Specialized Workflows</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sun, 03 May 2026 09:31:34 +0000</pubDate>
      <link>https://forem.com/dngzihng114379/why-ai-image-generators-are-moving-from-one-prompt-box-to-specialized-workflows-4j1e</link>
      <guid>https://forem.com/dngzihng114379/why-ai-image-generators-are-moving-from-one-prompt-box-to-specialized-workflows-4j1e</guid>
      <description>&lt;h1&gt;
  
  
  Why AI Image Generators Are Moving From "One Prompt Box" to Specialized Workflows
&lt;/h1&gt;

&lt;h2&gt;
  
  
  The Era of the Generic Prompt Box Is Ending
&lt;/h2&gt;

&lt;p&gt;In 2023, every AI image generator looked the same: a text box, a generate button, and a prayer.&lt;/p&gt;

&lt;p&gt;By 2026, the market has split. On one side, you have the incumbents — Midjourney, DALL-E, Stable Diffusion — competing on raw model quality. On the other side, a new wave of tools is emerging that focuses not on the model, but on the workflow.&lt;/p&gt;

&lt;p&gt;This shift matters for builders, designers, and anyone betting on the AI creative tools market.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With General-Purpose AI Image Tools
&lt;/h2&gt;

&lt;p&gt;General-purpose tools optimize for flexibility. You can generate anything — landscapes, portraits, logos, abstract art — with the right prompt.&lt;/p&gt;

&lt;p&gt;But flexibility comes at a cost:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The Prompt Engineering Tax&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To get good results from a general-purpose tool, you need to learn prompt engineering. "A cat" gives you a mediocre cat. "A photorealistic orange tabby cat sitting on a rustic wooden windowsill, golden hour lighting, shallow depth of field, shot on Canon EOS R5" gives you a great cat.&lt;/p&gt;

&lt;p&gt;Most users don't want to become prompt engineers. They want a great cat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The Context Switching Problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If I want to convert a photo to anime style, I don't want to write a prompt. I want to upload a photo and pick a style. If I want to design a tattoo, I don't want to guess which keywords produce clean line art on white backgrounds. I want to select "minimalist" from a dropdown.&lt;/p&gt;

&lt;p&gt;General-purpose tools force users to translate their intent into prompt language. Specialized tools eliminate that translation step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The Output Format Problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A sticker needs a white background and clean edges. A wallpaper needs specific aspect ratios. A profile photo needs face-centric composition. General-purpose tools don't enforce these constraints — users have to figure them out through trial and error (and wasted credits).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Specialized Workflow Approach
&lt;/h2&gt;

&lt;p&gt;The alternative is building separate interfaces for separate use cases, each with its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pre-built prompt templates&lt;/li&gt;
&lt;li&gt;Style presets relevant to that specific use case&lt;/li&gt;
&lt;li&gt;Output constraints (aspect ratio, background, composition)&lt;/li&gt;
&lt;li&gt;UI optimized for that workflow (file upload for image-to-image, style picker for design tools)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the approach behind tools like &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture&lt;/a&gt;, which offers distinct workflows for text-to-image, photo-to-anime, tattoo design, sticker creation, upscaling, and photo restoration — each with its own optimized interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for the Market
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Lower Barrier to Entry
&lt;/h3&gt;

&lt;p&gt;When you remove prompt engineering from the equation, you open the market to non-technical users. A tattoo artist exploring design concepts doesn't need to learn AI terminology — they pick a style and describe what they want in plain language.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Higher Conversion Rates
&lt;/h3&gt;

&lt;p&gt;Specialized landing pages for specific use cases (like "&lt;a href="https://24picture.com/ai-cyberpunk-generator.html" rel="noopener noreferrer"&gt;AI cyberpunk art generator&lt;/a&gt;") convert significantly better than generic "AI image generator" pages. Users searching for specific solutions have higher intent.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Defensible Differentiation
&lt;/h3&gt;

&lt;p&gt;Raw model quality is becoming commoditized. GPT-Image, Flux, Midjourney — they all produce impressive results. The differentiation is moving upstream to the workflow layer: how easy is it to get from intent to result?&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Better Unit Economics
&lt;/h3&gt;

&lt;p&gt;Credit-based pricing aligned to specific workflows enables better pricing. A quick sticker generation can cost less than a complex 4K upscale. This granularity isn't possible with flat subscription models.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Builders Should Take Away
&lt;/h2&gt;

&lt;p&gt;If you're building in the AI image space, consider:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't compete on model quality&lt;/strong&gt; — you'll lose to the incumbents with billion-dollar training budgets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compete on workflow design&lt;/strong&gt; — the gap between "technically possible" and "easy to do" is where value lives&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build for specific use cases&lt;/strong&gt; — "AI tattoo design tool" is a more winnable position than "AI image generator"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let users skip the prompt&lt;/strong&gt; — preset templates, style selectors, and smart defaults beat empty text boxes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The winners in AI creative tools won't be the ones with the best models. They'll be the ones who make the best models accessible to people who don't care about models.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>AI Image Generation Workflows Beyond Text-to-Image</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sun, 03 May 2026 09:30:46 +0000</pubDate>
      <link>https://forem.com/dngzihng114379/ai-image-generation-workflows-beyond-text-to-image-3041</link>
      <guid>https://forem.com/dngzihng114379/ai-image-generation-workflows-beyond-text-to-image-3041</guid>
      <description>&lt;h1&gt;
  
  
  5 AI Image Generation Workflows Beyond Text-to-Image
&lt;/h1&gt;

&lt;p&gt;Everyone talks about text-to-image AI. You type a prompt, you get a picture. Cool.&lt;/p&gt;

&lt;p&gt;But the real utility of AI image generation goes way beyond typing prompts into a box. Here are 5 workflows that are genuinely useful in 2026 — and how they actually work under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Photo to Anime Conversion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Upload a regular photo, get back an anime/manga-style version of the same image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's useful:&lt;/strong&gt; Social media avatars, profile pictures, gifts. People love seeing themselves in anime style without hiring an artist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works technically:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The input image is processed through an image-to-image model that applies style transfer while preserving the subject's key features — face shape, expression, pose. The trick is controlling how much of the original is preserved vs. how much is stylized.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture&lt;/a&gt;, users can choose from 6 anime styles ranging from subtle to heavy stylization. Each style maps to a different prompt template that controls the transformation intensity.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AI Upscaling (Image Enhancement)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Takes a low-resolution image and outputs a high-resolution version with added detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's useful:&lt;/strong&gt; Old photos, screenshots, thumbnails that need to be printed — any image that's too small for its intended use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works technically:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Modern upscaling models don't just interpolate pixels (like bicubic upscaling). They use trained neural networks that predict what the missing detail should look like based on the existing content. A face in a blurry photo gets sharper features because the model has learned what faces look like at high resolution.&lt;/p&gt;

&lt;p&gt;The quality difference between AI upscaling and traditional upscaling is dramatic — especially at 4x magnification.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. AI Tattoo Design
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Generate tattoo concepts from text descriptions, with style presets like traditional, minimalist, geometric, watercolor, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's useful:&lt;/strong&gt; Tattoos are permanent. Being able to explore dozens of variations before committing saves both money and regret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works technically:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is text-to-image with heavy prompt engineering. Each style preset injects specific instructions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Traditional" adds: bold outlines, limited color palette, American traditional style&lt;/li&gt;
&lt;li&gt;"Minimalist" adds: thin single-line drawing, minimal shading, white background&lt;/li&gt;
&lt;li&gt;"Geometric" adds: geometric shapes, symmetrical patterns, sacred geometry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The white/transparent background is forced to make it easy to visualize the design on skin.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Photo Restoration &amp;amp; Colorization
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Fixes damage in old photos (scratches, tears, fading) and adds realistic color to black-and-white images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's useful:&lt;/strong&gt; Family photos from decades ago can be brought back to life. This was previously a $50-100 professional service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works technically:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two-step pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Restoration&lt;/strong&gt;: An inpainting model identifies damaged areas and reconstructs them based on surrounding context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colorization&lt;/strong&gt;: A separate model predicts historically accurate colors based on the objects, clothing, and setting in the image&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The colorization step is particularly impressive with modern models — they can correctly color military uniforms, vintage cars, and natural landscapes based on era-appropriate references in their training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. AI Sticker Generation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Creates custom sticker designs with clean edges and white backgrounds, ready for printing or digital use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's useful:&lt;/strong&gt; Custom stickers for messaging apps, product packaging, event branding — all without a graphic designer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works technically:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key challenge is clean edges. Regular text-to-image models produce images with complex backgrounds that don't work as stickers. The solution is forcing a white background in the prompt and using a model that handles flat illustration styles well.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://24picture.com/ai-sticker.html" rel="noopener noreferrer"&gt;24picture's sticker maker&lt;/a&gt; forces these constraints automatically so users just describe what they want without worrying about technical details.&lt;/p&gt;




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

&lt;p&gt;Text-to-image gets all the hype, but these specialized workflows are where AI image generation becomes genuinely practical. They solve specific problems that people actually pay for.&lt;/p&gt;

&lt;p&gt;If you want to try any of these workflows: &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture.com&lt;/a&gt; offers all five with a free trial (50 credits on signup, no credit card required).&lt;/p&gt;

&lt;p&gt;The future of AI image tools isn't one giant prompt box — it's purpose-built workflows for specific creative tasks.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>How I Built an AI Image Generator That Actually Works in 2026</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Sun, 03 May 2026 09:29:00 +0000</pubDate>
      <link>https://forem.com/dngzihng114379/how-i-built-an-ai-image-generator-that-actually-works-in-2026-o01</link>
      <guid>https://forem.com/dngzihng114379/how-i-built-an-ai-image-generator-that-actually-works-in-2026-o01</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%2Faeugfbayzd07arqri8uk.webp" 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%2Faeugfbayzd07arqri8uk.webp" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
The AI image generation space has exploded. Everyone and their grandma has an "AI art generator" now. Most of them are just wrappers around the same API with a different color scheme.&lt;/p&gt;

&lt;p&gt;I wanted to build something different — a tool that actually solves real problems for real people, not just another prompt box.&lt;/p&gt;

&lt;p&gt;This is how I built &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture&lt;/a&gt;, and what I learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Most AI Image Tools
&lt;/h2&gt;

&lt;p&gt;Most AI image generators follow the same formula:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Text box for your prompt&lt;/li&gt;
&lt;li&gt;A "Generate" button&lt;/li&gt;
&lt;li&gt;Wait 30 seconds&lt;/li&gt;
&lt;li&gt;Get something that looks vaguely like what you asked for&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No guidance&lt;/strong&gt; — beginners have no idea how to write good prompts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One trick pony&lt;/strong&gt; — they only do text-to-image, nothing else&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expensive&lt;/strong&gt; — $20/month for 100 generations? No thanks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow&lt;/strong&gt; — 30-60 seconds per image is painful&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;24picture tackles all four problems:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. AI-Powered Prompt Assistance
&lt;/h3&gt;

&lt;p&gt;Instead of leaving users to figure out prompt engineering, I integrated an AI polish feature. You type a rough idea like "cat on a roof" and the system rewrites it into a detailed, high-quality prompt that actually produces great results.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Multiple Creative Workflows
&lt;/h3&gt;

&lt;p&gt;Text-to-image is just one feature. The platform also supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://24picture.com/photo-to-anime.html" rel="noopener noreferrer"&gt;Photo to Anime&lt;/a&gt;&lt;/strong&gt; — upload a photo, get an anime version&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Upscaling&lt;/strong&gt; — enhance low-resolution images to 4K&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Stickers&lt;/strong&gt; — generate custom sticker designs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Tattoo Design&lt;/strong&gt; — explore tattoo concepts before committing to ink&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo Restoration&lt;/strong&gt; — fix and colorize old photos&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each workflow has its own optimized interface instead of cramming everything into one generic prompt box.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Credit-Based Pricing
&lt;/h3&gt;

&lt;p&gt;No subscriptions. You buy credits, you use credits. Starting at $1.99 for 300 credits. One standard generation costs about 10 credits, so that's 30 images for less than $2.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Speed
&lt;/h3&gt;

&lt;p&gt;Most generations complete in under 15 seconds. The key is choosing the right model for each use case instead of routing everything through the same pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Architecture (High Level)
&lt;/h2&gt;

&lt;p&gt;The stack is intentionally simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: Vanilla HTML/CSS/JS — no framework overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: PHP 8.4 + MySQL 8.4&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server&lt;/strong&gt;: Nginx on a single VPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Models&lt;/strong&gt;: Multiple providers, routed per feature&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt;: Magic link (passwordless email login)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why no React/Next.js? Because for a tool like this, the added complexity doesn't justify itself. The pages load in under 1 second. The UX is smooth. SEO works out of the box because every page is plain HTML.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Lesson 1: Prompt Templates Beat Prompt Boxes
&lt;/h3&gt;

&lt;p&gt;For specialized tools like tattoo design or sticker generation, pre-built prompt templates with user-selectable styles convert 3x better than empty text boxes.&lt;/p&gt;

&lt;p&gt;Users don't want to learn prompt engineering. They want results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 2: Credits &amp;gt; Subscriptions for New Products
&lt;/h3&gt;

&lt;p&gt;When you're unknown, asking for $20/month is a huge trust barrier. Credits let people try the product for $2 and come back when they're convinced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 3: SEO Still Works for AI Tools
&lt;/h3&gt;

&lt;p&gt;Despite the "AI search is killing SEO" narrative, programmatic landing pages for specific use cases (like "&lt;a href="https://24picture.com/ai-cyberpunk-generator.html" rel="noopener noreferrer"&gt;AI Cyberpunk Generator&lt;/a&gt;") still drive real organic traffic. The key is matching search intent exactly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 4: Ship the Ugly Version First
&lt;/h3&gt;

&lt;p&gt;My first version looked terrible. But it worked. And that's what mattered. I spent 2 weeks on functionality before touching CSS. Best decision I made.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;More specialized generators (avatar, wallpaper, headshot)&lt;/li&gt;
&lt;li&gt;Batch generation for power users&lt;/li&gt;
&lt;li&gt;API access for developers&lt;/li&gt;
&lt;li&gt;Community gallery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it out: &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture.com&lt;/a&gt; — new users get 50 free credits, no credit card needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions?
&lt;/h2&gt;

&lt;p&gt;Drop a comment below if you're building something similar or have questions about the architecture. Happy to share more details.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>We shipped an AI Cyberpunk Generator for 24picture</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Fri, 01 May 2026 07:16:59 +0000</pubDate>
      <link>https://forem.com/dngzihng114379/we-shipped-an-ai-cyberpunk-generator-for-24picture-153d</link>
      <guid>https://forem.com/dngzihng114379/we-shipped-an-ai-cyberpunk-generator-for-24picture-153d</guid>
      <description>&lt;p&gt;Today we shipped a new landing page for 24picture: an &lt;strong&gt;AI Cyberpunk Generator&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Link:&lt;br&gt;
&lt;a href="https://24picture.com/ai-cyberpunk-generator.html" rel="noopener noreferrer"&gt;https://24picture.com/ai-cyberpunk-generator.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The goal was simple: keep building SEO-focused use-case pages around high-intent image generation queries, while making sure each page is still genuinely useful to users.&lt;/p&gt;

&lt;p&gt;Instead of publishing a thin “keyword page”, we built a full landing page with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a clear product-focused headline&lt;/li&gt;
&lt;li&gt;prompt examples for cyberpunk scenes&lt;/li&gt;
&lt;li&gt;use cases like wallpapers, thumbnails, album covers, and concept art&lt;/li&gt;
&lt;li&gt;FAQ schema for richer search visibility&lt;/li&gt;
&lt;li&gt;internal links from the homepage, create page, and core generator page&lt;/li&gt;
&lt;li&gt;sitemap updates for faster discovery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The keyword angle here is &lt;strong&gt;AI cyberpunk generator&lt;/strong&gt;, but the real intent behind it is broader:&lt;br&gt;
people want to generate neon cityscapes, android portraits, rainy dystopian streets, cyberpunk anime, and futuristic wallpaper-style visuals without needing Photoshop or 3D tools.&lt;/p&gt;

&lt;p&gt;So the page was written to serve both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;search engines&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;real users looking for inspiration and prompts&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few things I’m learning while building 24picture in public:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Programmatic SEO works better when the page is actually helpful
&lt;/h2&gt;

&lt;p&gt;It’s easy to mass-produce pages with swapped keywords.&lt;br&gt;&lt;br&gt;
It’s much harder — and much more worthwhile — to create pages that genuinely help someone get a better result.&lt;/p&gt;

&lt;p&gt;For this one, that meant including prompt structure, style keywords, and realistic use cases instead of just repeating the target phrase.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Internal linking matters more than people think
&lt;/h2&gt;

&lt;p&gt;We didn’t just publish the page and leave it isolated.&lt;/p&gt;

&lt;p&gt;We also linked it from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the homepage footer&lt;/li&gt;
&lt;li&gt;the create page&lt;/li&gt;
&lt;li&gt;the main text-to-image page&lt;/li&gt;
&lt;li&gt;the sitemap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives both users and crawlers a better path to find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Shipping small pages consistently is better than waiting for “perfect”
&lt;/h2&gt;

&lt;p&gt;This wasn’t a massive feature launch.&lt;br&gt;&lt;br&gt;
It was one focused page, shipped fast, tied to a clear keyword and a clear user intent.&lt;/p&gt;

&lt;p&gt;That’s the kind of compounding work we’re trying to do with 24picture.&lt;/p&gt;

&lt;p&gt;Next up: more landing pages, more comparison pages, and more experiments around AI image search intent.&lt;/p&gt;

&lt;p&gt;If you’re also building SEO pages for an indie product, I’d love to hear how you balance search intent vs actual usefulness.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>seo</category>
    </item>
    <item>
      <title>How I Built an AI Image Studio with 9 Tools Using PHP 8.4 — Architecture, Bugs &amp; Lessons</title>
      <dc:creator>Zihang Dong 董子航</dc:creator>
      <pubDate>Wed, 29 Apr 2026 09:17:57 +0000</pubDate>
      <link>https://forem.com/dngzihng114379/how-i-built-an-ai-image-studio-with-9-tools-using-php-84-architecture-bugs-lessons-327i</link>
      <guid>https://forem.com/dngzihng114379/how-i-built-an-ai-image-studio-with-9-tools-using-php-84-architecture-bugs-lessons-327i</guid>
      <description>&lt;p&gt;I just launched &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture&lt;/a&gt; — an all-in-one AI image studio with 9 specialized tools. Built with PHP 8.4, MySQL 8.4, and Nginx.&lt;/p&gt;

&lt;p&gt;In this post I'll share the architecture decisions, the nastiest bug I encountered, and what I learned shipping a real AI product.&lt;/p&gt;

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

&lt;p&gt;Instead of yet another "text to image" wrapper, I built a platform with 9 tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text to Image&lt;/strong&gt; — 3 AI models, up to 4K resolution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image to Image&lt;/strong&gt; — transform existing photos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Upscale&lt;/strong&gt; — enhance images up to 4K&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Model&lt;/strong&gt; — virtual model photography&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Tattoo Design&lt;/strong&gt; — custom flash sheets in 7 tattoo styles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Sticker Maker&lt;/strong&gt; — die-cut stickers from text prompts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo to Anime&lt;/strong&gt; — turn photos into anime art&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Photo Notes&lt;/strong&gt; — stylized photo annotations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Polish&lt;/strong&gt; — one-click prompt enhancement via DeepSeek API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All tools share the same backend — one &lt;code&gt;create.php&lt;/code&gt; to submit jobs, one &lt;code&gt;status.php&lt;/code&gt; to poll results. The frontend does prompt template assembly per tool.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;PHP 8.4 (no framework)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MySQL 8.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web Server&lt;/td&gt;
&lt;td&gt;Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN / Security&lt;/td&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI Models&lt;/td&gt;
&lt;td&gt;Nano Banana 2, NB Pro, GPT Image (via kie.ai API)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt Enhancement&lt;/td&gt;
&lt;td&gt;DeepSeek API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;Resend API (magic link auth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Creem (credit-based, no subscription)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why PHP with No Framework?
&lt;/h3&gt;

&lt;p&gt;Controversial take: for an API-centric product with ~15 endpoints, a framework adds more complexity than it solves. Raw PHP 8.4 with PDO, strict typing, and a single &lt;code&gt;config.php&lt;/code&gt; bootstrap keeps things dead simple. The entire backend is about 2,000 lines of code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: One Backend, Nine Tools
&lt;/h2&gt;

&lt;p&gt;The key design decision was making all 9 tools share the same backend pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend (per-tool UI)
  → assembles prompt from user inputs
  → POST /api/generate/create.php
    → deduct credits
    → call AI API
    → save generation record
  → poll GET /api/generate/status.php
    → check AI API status
    → on success: save result, create thumbnail, send email
    → return result to frontend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a new tool means creating a new HTML page that assembles a different prompt template. Zero backend changes. I went from 3 tools to 9 in a single day using this pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Nastiest Bug: 4K Thumbnails Crashing PHP
&lt;/h2&gt;

&lt;p&gt;This one took me a while to figure out. Users would generate a 4K image successfully, but the status endpoint would return a 500 error — every single time. The image was ready on the AI provider's side, but our server kept crashing when trying to process it.&lt;/p&gt;

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

&lt;p&gt;When a generation completes, &lt;code&gt;status.php&lt;/code&gt; creates a thumbnail using PHP's GD library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;imagecreatefromstring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$imgData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 4K image (roughly 4096 × 2304 pixels), GD needs to hold the uncompressed bitmap in memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4096 × 2304 × 4 bytes (RGBA) × 2.5 (overhead) ≈ 94 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP's default memory limit is 128MB. With the rest of the script's memory usage, this pushed it over the edge. The fatal memory error killed the script &lt;strong&gt;before&lt;/strong&gt; the database was updated, so the next poll attempt would try the same thing — an infinite crash loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Three changes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Update the database FIRST, before attempting the thumbnail:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mark as success immediately&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UPDATE generations SET status = "success", 
    result_url = ?, completed_at = NOW() WHERE task_id = ?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$resultUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$taskId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// THEN try thumbnail (best-effort)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$thumbUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createThumbnail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$resultUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$gen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;error_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Thumbnail failed: "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&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;Even if the thumbnail crashes, the next poll returns cached success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Memory estimation before loading the image:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;getimagesizefromstring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$imgData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$needed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$info&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="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$info&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="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'channels'&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="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'bits'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$needed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip thumbnail for huge images&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;ini_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'memory_limit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'256M'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Wrap everything after the DB update in try-catch&lt;/strong&gt; so no post-processing error can block the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson Learned
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Always persist your state change before doing expensive side effects. If your script can crash during step N, make sure steps 1 through N-1 are already committed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This applies to any pipeline: payment processing, webhook handling, email sending — commit the critical state first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credit System Design
&lt;/h2&gt;

&lt;p&gt;I chose pay-as-you-go credits instead of subscriptions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;1K&lt;/th&gt;
&lt;th&gt;2K&lt;/th&gt;
&lt;th&gt;4K&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nano Banana 2&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NB Pro&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT Image&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Credits never expire. Free credits on signup. This removes the "am I getting my money's worth?" anxiety that subscriptions create.&lt;/p&gt;

&lt;p&gt;For the backend, credit deduction uses a simple &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; lock to prevent race conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;beginTransaction&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT credits FROM users WHERE id = ? FOR UPDATE'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$credits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchColumn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$credits&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;rollBack&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Insufficient credits'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UPDATE users SET credits = credits - ? WHERE id = ?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important: release the lock &lt;strong&gt;before&lt;/strong&gt; calling the external AI API. Don't hold database locks across network calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Checklist (Things I Almost Forgot)
&lt;/h2&gt;

&lt;p&gt;A quick list for anyone shipping a similar product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Session cookies: &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite=Lax&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;✅ File upload validation: &lt;code&gt;getimagesize()&lt;/code&gt; server-side, not just MIME&lt;/li&gt;
&lt;li&gt;✅ Download proxy: whitelist allowed domains to prevent SSRF&lt;/li&gt;
&lt;li&gt;✅ Rate limiting on contact forms (IP-based)&lt;/li&gt;
&lt;li&gt;✅ XSS escaping on all user-generated content in the frontend&lt;/li&gt;
&lt;li&gt;✅ SQL injection prevention: parameterized queries everywhere&lt;/li&gt;
&lt;li&gt;✅ Feature whitelist: validate &lt;code&gt;feature&lt;/code&gt; parameter against allowed values&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The product is live at &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture.com&lt;/a&gt;. Currently focused on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Growing organic traffic through SEO and content&lt;/li&gt;
&lt;li&gt;Adding more specialized tools (AI Interior Design, AI Product Photo)&lt;/li&gt;
&lt;li&gt;Building a Chrome extension for right-click AI upscale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar, I hope this post saves you from the 4K thumbnail crash and the database lock pitfall. Happy to answer questions in the comments!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you want to try it out, there are free credits on signup — no card required: &lt;a href="https://24picture.com" rel="noopener noreferrer"&gt;24picture.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <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://forem.com/dngzihng114379/i-built-a-browser-based-pixel-art-converter-using-canvas-median-cut-and-floyd-steinberg-dithering-12o4</link>
      <guid>https://forem.com/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://forem.com/dngzihng114379/building-a-browser-based-keyboard-tester-with-vanilla-javascript-96g</link>
      <guid>https://forem.com/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://forem.com/dngzihng114379/i-analyzed-1000-professional-emails-here-are-the-5-patterns-that-get-replies-32m8</link>
      <guid>https://forem.com/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://forem.com/dngzihng114379/how-i-built-a-free-qr-code-generator-that-runs-100-in-your-browserpublished-true-2aim</link>
      <guid>https://forem.com/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://forem.com/dngzihng114379/i-built-30-free-browser-tools-in-one-month-heres-what-i-learned-kbh</link>
      <guid>https://forem.com/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>
  </channel>
</rss>
