<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roman Popovych</title>
    <description>The latest articles on DEV Community by Roman Popovych (@forze-dev).</description>
    <link>https://dev.to/forze-dev</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%2F3893066%2Fdb952072-b49d-4b26-b925-bd2794cfce82.jpg</url>
      <title>DEV Community: Roman Popovych</title>
      <link>https://dev.to/forze-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/forze-dev"/>
    <language>en</language>
    <item>
      <title>How I Built a Privacy-First Browser Tool Suite to Fix My Own Freelance Workflow (No Backend, No Uploads, No Nonsense)</title>
      <dc:creator>Roman Popovych</dc:creator>
      <pubDate>Sun, 26 Apr 2026 14:47:48 +0000</pubDate>
      <link>https://dev.to/forze-dev/how-i-built-a-privacy-first-browser-tool-suite-to-fix-my-own-freelance-workflow-no-backend-no-o48</link>
      <guid>https://dev.to/forze-dev/how-i-built-a-privacy-first-browser-tool-suite-to-fix-my-own-freelance-workflow-no-backend-no-o48</guid>
      <description>&lt;p&gt;Ever had one of those freelance days where you spend more time switching between tabs than actually building something?&lt;/p&gt;

&lt;p&gt;Client sends over 10 raw photos, 3MB each. No logo. No favicon. SEO meta tags that need to be written from scratch. You open one site to convert images, another to compress them, a third to generate favicons. One of them has a casino ad that takes up half the screen. Another one slaps a watermark on your file without warning. A third one probably uploads your images somewhere and you have no idea where they go.&lt;/p&gt;

&lt;p&gt;I counted once. Six different sites for one client project. Before writing a single line of code.&lt;/p&gt;

&lt;p&gt;At some point I just got tired of it and decided to build my own thing. Not because I thought it would be easy, but because I had 1-2 hours after work and nothing better to do with them.&lt;/p&gt;

&lt;p&gt;That project is &lt;a href="https://devtools.abect.com" rel="noopener noreferrer"&gt;devtools.abect.com&lt;/a&gt; — a set of free browser-based tools for developers. Image converters, compressors, favicon generator, SEO meta tag generator. No backend, no account, no watermarks, no ads.&lt;/p&gt;

&lt;p&gt;This is the story of how it works under the hood.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem with "Free" Online Tools
&lt;/h2&gt;

&lt;p&gt;Most online converters work the same way: you pick a file, it gets uploaded to their server, processed somewhere you can't see, and sent back to you. You just hand over your client's assets to a random server and hope for the best.&lt;/p&gt;

&lt;p&gt;For personal photos, maybe fine. For client work — internal screenshots, unreleased brand assets, documents — that's a different story.&lt;/p&gt;

&lt;p&gt;Beyond privacy, there's the UX problem. File size limits. Daily caps on the free tier. Watermarks you don't notice until you've already sent the file to the client. Ads that make the page unusable on mobile.&lt;/p&gt;

&lt;p&gt;I wanted something that just works. No friction, no accounts, no surprises.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: Why Zero Backend?
&lt;/h2&gt;

&lt;p&gt;The core idea was simple: run everything in the browser.&lt;/p&gt;

&lt;p&gt;No server means no uploads. No uploads means no privacy risk. No server also means no hosting costs, no infrastructure to maintain, and no rate limits to worry about.&lt;/p&gt;

&lt;p&gt;Modern browsers are surprisingly capable. Between the Canvas API, File API, Blob URLs, and Web Crypto, you can do serious image processing entirely client-side. The question wasn't "can this be done?" but "how far can this actually go?"&lt;/p&gt;

&lt;p&gt;Turns out — pretty far.&lt;/p&gt;

&lt;p&gt;Here is the full stack of browser APIs the project uses, and what each one actually does:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canvas API&lt;/strong&gt; — the core of every image operation. Draws the image onto an off-screen canvas, applies transformations, and exports to the target format via &lt;code&gt;toBlob()&lt;/code&gt;. Every converter, compressor, and the favicon renderer goes through this pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File API&lt;/strong&gt; — reads files dropped or selected by the user directly in the browser. Files never touch a network request. They go straight into the Canvas pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blob URL API&lt;/strong&gt; — creates in-memory object URLs for previews and downloads. &lt;code&gt;URL.createObjectURL()&lt;/code&gt; gives you an instant download link from raw binary data, no server round-trip required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Crypto API&lt;/strong&gt; — generates unique IDs for each file in a batch queue using &lt;code&gt;crypto.randomUUID()&lt;/code&gt;. Stateless, no tracking, collision-proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSZip&lt;/strong&gt; — assembles ZIP archives from Blob objects inside the browser tab. Batch download of 20 converted images: one ZIP, zero server calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypedArrays + DataView&lt;/strong&gt; — used specifically for &lt;code&gt;.ico&lt;/code&gt; file generation. More on this in a second.&lt;/p&gt;

&lt;p&gt;Everything verifiable. Open DevTools, go to the Network tab, drop a file in. You will see zero outgoing file transfers.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Image Pipeline Actually Works
&lt;/h2&gt;

&lt;p&gt;The Canvas-based image conversion pipeline is straightforward once you understand it, but there are a few non-obvious details.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Step 1: load the file into an HTMLImageElement&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: draw onto an off-screen canvas&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&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;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;bitmap&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="c1"&gt;// Step 3: export to target format&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`image/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;targetFormat&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="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OffscreenCanvas&lt;/code&gt; is the key detail here. It runs off the main thread so the UI stays responsive when processing a batch of 20 images. No freezing, no spinner blocking the page.&lt;/p&gt;

&lt;p&gt;For compression, the same pipeline applies — same format in and out, just a lower &lt;code&gt;quality&lt;/code&gt; value. The live preview before download is just a second canvas render at the target quality, displayed immediately.&lt;/p&gt;

&lt;p&gt;One edge case that took some debugging: AVIF export via &lt;code&gt;convertToBlob&lt;/code&gt; has inconsistent browser support. Chrome supports it, Firefox does not. The solution was a format support detection step at initialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectSupportedFormats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;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;formats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&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;avif&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&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;supported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;formats&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;dataURL&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="s2"&gt;`image/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fmt&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="nx"&gt;supported&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataURL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data:image/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fmt&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;supported&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;If AVIF is not supported, the option is hidden from the UI rather than shown as a broken feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Interesting Part: Building .ico Files from Scratch
&lt;/h2&gt;

&lt;p&gt;This was the part I did not expect to spend time on.&lt;/p&gt;

&lt;p&gt;Most favicon generators either use a library or just output a PNG and rename it &lt;code&gt;.ico&lt;/code&gt;. Neither approach is quite right. A proper &lt;code&gt;.ico&lt;/code&gt; file is a binary container format that can hold multiple image sizes in a single file. Browsers pick the right one depending on context.&lt;/p&gt;

&lt;p&gt;The Canvas API cannot produce &lt;code&gt;.ico&lt;/code&gt; files natively. &lt;code&gt;toBlob()&lt;/code&gt; supports &lt;code&gt;image/png&lt;/code&gt;, &lt;code&gt;image/jpeg&lt;/code&gt;, &lt;code&gt;image/webp&lt;/code&gt; — that's it. So building a multi-size &lt;code&gt;.ico&lt;/code&gt; had to be done manually with &lt;code&gt;TypedArrays&lt;/code&gt; and &lt;code&gt;DataView&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the structure of an ICO file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ICONDIR header]       — 6 bytes
[ICONDIRENTRY × N]     — 16 bytes per image
[Image data × N]       — PNG or BMP blobs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildIcoFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pngBlobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// pngBlobs: array of { blob, width, height }&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;pngBlobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headerSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&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="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// calculate total buffer size&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headerSize&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pngBlobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;blob&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&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;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalSize&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;view&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;DataView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// ICONDIR header&lt;/span&gt;
  &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint16&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// reserved, must be 0&lt;/span&gt;
  &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint16&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// type: 1 = ICO&lt;/span&gt;
  &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&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;// number of images&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dataOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headerSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;pngBlobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;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;size&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entryOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&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;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;256&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&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;height&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&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="c1"&gt;// color palette count (0 = no palette)&lt;/span&gt;
    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// reserved&lt;/span&gt;
    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&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="mi"&gt;1&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;// color planes&lt;/span&gt;
    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&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;// bits per pixel&lt;/span&gt;
    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&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="nx"&gt;size&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;// image data size&lt;/span&gt;
    &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryOffset&lt;/span&gt; &lt;span class="o"&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;dataOffset&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;// offset from file start&lt;/span&gt;

    &lt;span class="nx"&gt;dataOffset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// write PNG blobs into the buffer after the directory&lt;/span&gt;
  &lt;span class="c1"&gt;// (requires converting each Blob to ArrayBuffer via FileReader or arrayBuffer())&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each PNG is rendered at 16x16, 32x32, 48x48, and 64x64 via Canvas, then packed into a single ICO container. The result downloads as a proper multi-resolution favicon — same as what a desktop application would produce.&lt;/p&gt;

&lt;p&gt;This was the part that required actually reading the ICO spec and working through the binary layout by hand. No library. Just &lt;code&gt;DataView&lt;/code&gt;, a lot of offset arithmetic, and eventually a working &lt;code&gt;.ico&lt;/code&gt; file.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned About AI-Assisted Development
&lt;/h2&gt;

&lt;p&gt;The entire project was built with Claude Code. I set a rule at the start: no hand-written code. I wanted to see how far that could go.&lt;/p&gt;

&lt;p&gt;It went pretty far — but not in the way I expected.&lt;/p&gt;

&lt;p&gt;The AI handles boilerplate well. Repetitive logic, utility functions, component scaffolding — fast and clean. Where it struggles is multi-step browser API chains with edge cases. The Canvas pipeline for batch processing with &lt;code&gt;OffscreenCanvas&lt;/code&gt; required several correction rounds. The ICO binary layout had to be verified offset by offset.&lt;/p&gt;

&lt;p&gt;The role I ended up playing was less "developer" and more "spec reader + QA." I read the ICO binary format documentation, checked the Canvas API behavior across browsers, and caught the places where the output was technically valid but practically wrong. The AI wrote the code; I told it why it was wrong and what to fix.&lt;/p&gt;

&lt;p&gt;That's a different workflow, not a better or worse one. Just different.&lt;/p&gt;




&lt;h2&gt;
  
  
  SEO Structure: One Tool, One URL
&lt;/h2&gt;

&lt;p&gt;One decision that shaped the whole project was URL structure.&lt;/p&gt;

&lt;p&gt;The obvious approach is a single converter page: &lt;code&gt;/convert?from=png&amp;amp;to=jpg&lt;/code&gt;. One page, one component, all formats handled by query params.&lt;/p&gt;

&lt;p&gt;I went the other direction: &lt;code&gt;/png-to-jpg&lt;/code&gt;, &lt;code&gt;/jpg-to-webp&lt;/code&gt;, &lt;code&gt;/webp-to-avif&lt;/code&gt; — each format pair gets its own page with its own content, meta tags, and JSON-LD schema.&lt;/p&gt;

&lt;p&gt;The reasoning: someone searching "png to jpg online" is not the same person as someone searching "compress webp file." Different intent, different content, different page. A generic converter page captures neither well.&lt;/p&gt;

&lt;p&gt;Each tool page has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;HowTo&lt;/code&gt; JSON-LD schema with numbered steps&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;FAQPage&lt;/code&gt; schema with 8-9 questions (same content renders as the visible FAQ, no duplication)&lt;/li&gt;
&lt;li&gt;A format comparison table (good for featured snippet capture)&lt;/li&gt;
&lt;li&gt;Implementation guides with code examples for React, Next.js, Vue, and WordPress&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether this actually moves rankings is an open question. I started tracking in Google Search Console after the SEO overhaul in late April. Will share results in a follow-up post once there is enough data to say anything useful.&lt;/p&gt;




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

&lt;p&gt;Right now the project has 26 tools. A few things I'm weighing for what comes next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD Generator&lt;/strong&gt; — fill in a form, get valid structured data for any schema type. Useful for the same workflow that led to building the meta tag generator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG Optimizer&lt;/strong&gt; — strip unnecessary metadata and reduce file size client-side. SVGO in the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS Gradient Generator&lt;/strong&gt; — simple, high search volume, fits the browser-based model well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The constraint is time, not ideas. One new tool per week is realistic while keeping the existing ones properly maintained.&lt;/p&gt;




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

&lt;p&gt;&lt;a href="https://devtools.abect.com" rel="noopener noreferrer"&gt;devtools.abect.com&lt;/a&gt; — free, no account, no watermarks, no ads. Works offline after the first load.&lt;/p&gt;

&lt;p&gt;If you find a bug or something behaves wrong in your browser, I want to know. And if you have a tool you keep opening a separate site for — tell me in the comments. That's basically how this whole project started.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>ai</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
