<?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: tomcate</title>
    <description>The latest articles on DEV Community by tomcate (@tomcate_9c4562b60a3ac8fe4).</description>
    <link>https://dev.to/tomcate_9c4562b60a3ac8fe4</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4125662%2Fc5cb08c8-f25c-4fd1-ac27-dd588b93bb32.jpg</url>
      <title>DEV Community: tomcate</title>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tomcate_9c4562b60a3ac8fe4"/>
    <language>en</language>
    <item>
      <title>Making a Tool Site Legible to AI Assistants: GEO in Practice</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Tue, 22 Sep 2026 10:38:22 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/making-a-tool-site-legible-to-ai-assistants-geo-in-practice-2mof</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/making-a-tool-site-legible-to-ai-assistants-geo-in-practice-2mof</guid>
      <description>&lt;p&gt;There is a new front door on the web, and it does not have a search box. When someone asks ChatGPT, Perplexity, or Claude for "a tool to convert HEIC photos", the assistant picks from what it can see. If your site is invisible to that process, you are not in the consideration set — regardless of your Google ranking.&lt;/p&gt;

&lt;p&gt;This post is the practical checklist we run on &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article4" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; (166 browser-local developer tools), with the actual crawler numbers from our logs and the mistakes that cost us rework.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the uncomfortable physics: AI crawlers do not run JavaScript
&lt;/h2&gt;

&lt;p&gt;GPTBot, ClaudeBot, PerplexityBot and friends fetch HTML and read. They do not hydrate React, they do not execute your bundle, and they will not wait for your client-side rendering to finish. A JavaScript-only tool page arrives at the model as an empty shell.&lt;/p&gt;

&lt;p&gt;Our site is statically exported (998 pre-rendered pages), which made this a non-issue — but if your content lives behind client rendering, that is item zero on this list. Test it the honest way: &lt;code&gt;curl&lt;/code&gt; your page and look for the actual content in the HTML. Not "view source in DevTools" — the raw bytes a crawler gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  llms.txt: useful convention, fatal if hand-maintained
&lt;/h2&gt;

&lt;p&gt;llms.txt is the emerging convention for telling AI agents what your site offers. Ours covers the tool catalog, the privacy model, and per-category entry points, in both English and Chinese.&lt;/p&gt;

&lt;p&gt;The lesson that cost us a month: &lt;strong&gt;generate it at build time from your single source of truth, never hand-edit it.&lt;/strong&gt; Our first version was hand-written. It rotted in the exact ways hand-maintained files always rot: duplicate section titles after a copy-paste, tool counts drifting from reality ("130+ tools" when we had 144), a Chinese edition that never existed because nobody remembered to add one. An llms.txt that misdescribes your site is worse than none — it confidently teaches models the wrong thing.&lt;/p&gt;

&lt;p&gt;Now a build step regenerates it from the same registry that renders the site. Counts are always exact; the file cannot drift because nobody edits it by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  robots.txt: decide, explicitly, who you let in
&lt;/h2&gt;

&lt;p&gt;The default posture many sites discover they have (blocking everything unrecognized) or the opposite (allowing everything silently) are both decisions — just unmade ones. Ours is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight robot_framework"&gt;&lt;code&gt;User-agent: GPTBot&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Allow:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...for every AI crawler we know about, alongside the standard search bots. This is a values call: we want assistants to be able to recommend local-first tools accurately, because the privacy story is the product. A paywalled-content site might reasonably reach the opposite conclusion. Make the call on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reward loop is real, and measurable
&lt;/h2&gt;

&lt;p&gt;After the crawler allowances went live, our nginx logs filled up. The current steady state: &lt;strong&gt;400-1000 AI crawler requests per day&lt;/strong&gt; — GPTBot, PerplexityBot, ClaudeBot, Amazonbot, PetalBot — systematically walking the catalog. And then the loop closes: referral traffic from &lt;code&gt;perplexity.ai&lt;/code&gt; and &lt;code&gt;chatgpt.com&lt;/code&gt; starts appearing in the same logs. Small numbers yet, but the direction is established: assistants that can read your site will cite it, and their users click through.&lt;/p&gt;

&lt;p&gt;We also push every new page through IndexNow (Bing's protocol, which ChatGPT Search partly sits on). It costs one POST request and removes the discovery delay for the engines that honor it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not matter (for us, so far)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Structured data beyond the basics: we ship standard FAQ and tool schemas, but there is no evidence AI assistants consume JSON-LD preferentially over readable HTML.&lt;/li&gt;
&lt;li&gt;Per-prompt optimization tricks: the content that models quote is the content that answers the question plainly. Our per-tool FAQ sections — written for humans — are what surfaces in AI answers.&lt;/li&gt;
&lt;li&gt;Submitting to "AI search engines" directories: the crawlers found us through standard crawling plus IndexNow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist, condensed
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Raw HTML contains your content (curl test) — client-rendered sites fix this first&lt;/li&gt;
&lt;li&gt;llms.txt generated from the build, never hand-edited&lt;/li&gt;
&lt;li&gt;robots.txt with explicit AI-crawler allowances (a decision, not an accident)&lt;/li&gt;
&lt;li&gt;IndexNow for every new page&lt;/li&gt;
&lt;li&gt;FAQ-shaped content that answers real questions in plain language&lt;/li&gt;
&lt;li&gt;Measure: watch crawler UAs in your access logs, then watch for AI-domain referers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is hard. All of it is the difference between being describable and being invisible when someone asks an assistant what to use.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Post #4 in the local-first series: &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;#1 why 166 no-upload tools&lt;/a&gt;, &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-inclu"&gt;#2 browser-side AI segmentation&lt;/a&gt;, &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/merging-pdfs-without-a-server-the-bytes-under-a-structural-merge-109f"&gt;#3 merging PDFs without a server&lt;/a&gt;. All tools: &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article4" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Merging PDFs Without a Server: The Bytes Under a Structural Merge</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Mon, 21 Sep 2026 04:48:59 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/merging-pdfs-without-a-server-the-bytes-under-a-structural-merge-109f</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/merging-pdfs-without-a-server-the-bytes-under-a-structural-merge-109f</guid>
      <description>&lt;p&gt;My &lt;a href="https://dev.to/tomcate_9c452b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-inclu"&gt;last post&lt;/a&gt; covered running AI in the browser. This one is about the quieter member of the local-first family: PDF processing — merging, splitting, compressing — and what actually happens to the bytes when no server is involved.&lt;/p&gt;

&lt;p&gt;The punchline first: a proper PDF merge does not "read" your documents the way a PDF viewer does. It rearranges serialized objects. Understanding that one sentence explains most of the behavior people find surprising in merge tools — and it shows why the whole operation fits comfortably in a browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PDF is a database, not a document
&lt;/h2&gt;

&lt;p&gt;Strip the format mystique away and a PDF file is a miniature object database: a header, a trail of objects (pages, fonts, images, streams), a cross-reference table pointing to each object byte offset, and a trailer. "Opening" a PDF means parsing that xref table, loading the page tree, and resolving references. Rendering is just one thing you can do after parsing.&lt;/p&gt;

&lt;p&gt;Which means the three classic operations decompose like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merge&lt;/strong&gt; = copy the page objects of file B into file A's object space, remap their references, rebuild one xref table&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split&lt;/strong&gt; = the mirror image: keep a subset of the page tree, carry over only the objects those pages reference&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compress (for scanned PDFs)&lt;/strong&gt; = re-encode each page's embedded image stream (JPEG quality is your dial)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that requires rendering a single pixel. That is why &lt;a href="https://www.wcytcn.com/tools/merge-pdf?utm_source=devto&amp;amp;utm_medium=article3" rel="noopener noreferrer"&gt;our merge tool&lt;/a&gt; describes itself as "structural": pages come out byte-identical, text stays selectable, and a vector chart does not degrade, because it was never rasterized in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pdf-lib gives you — and what it makes obvious
&lt;/h2&gt;

&lt;p&gt;In the browser this is all &lt;a href="https://github.com/Hopding/pdf-lib" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt;, which is pure JavaScript with zero dependencies on native code or workers. The merge core is almost disappointingly small:&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;out&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;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&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;src&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;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;pages&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPageIndices&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;pages&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;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;bytes&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;copyPages&lt;/code&gt; is where the object-remapping happens. Two things you learn from living with this API:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page order is list order.&lt;/strong&gt; There is no implicit sorting, no filename intelligence. If users expect page 10 to land after page 9, your UI has to enforce it — which is why reorder arrows exist and why "list order becomes page order" is the honest description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the tool does not preserve is as informative as what it does.&lt;/strong&gt; Bookmarks that point to page positions shift. Form fields sharing a name across documents can collide — pdf-lib will not deduplicate them for you. Encrypted files need decryption before load or they throw. None of these are bugs in the library; they are the format showing its database nature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compression is where honesty matters
&lt;/h2&gt;

&lt;p&gt;The interesting decision in a browser PDF compressor is what to do when compression makes the file &lt;em&gt;bigger&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Scanned PDFs — one big image per page — compress beautifully: re-encode each page image as JPEG at quality 50-65 and 60-90% of the size evaporates, because the 300 DPI scan carries resolution nobody consumes. Vector PDFs generated by Word or LaTeX are already tiny; re-rendering them as images inflates them.&lt;/p&gt;

&lt;p&gt;So the honest compressor has three behaviors: compress scans aggressively, and when the output grows, say so (&lt;code&gt;+12%&lt;/code&gt; on the button, not a silent download) and point users toward splitting instead. Tools that always claim victory are lying to somebody.&lt;/p&gt;

&lt;p&gt;The browser implementation detail that surprised me: canvas-mediated JPEG re-encoding of page images is where the quality dial actually lives, and quality-to-size is emphatically not linear — the useful cliff for scanned documents sits between 50 and 65, where halving the quality costs you single-digit percent of visual fidelity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy argument, restated for files people actually have
&lt;/h2&gt;

&lt;p&gt;For portrait photos, local processing is a nice property. For PDFs it is closer to the whole point, because the documents that trigger PDF operations are disproportionately the sensitive ones — contracts under negotiation, visa applications, bank statements. The flow "I need to merge two files" should not have a mandatory step of "upload my signed contract to a random web service."&lt;/p&gt;

&lt;p&gt;The verification is the same as always: DevTools, Network panel, watch zero requests leave while you work. Our &lt;a href="https://www.wcytcn.com/en/blog/pdf-tools-complete-guide?utm_source=devto&amp;amp;utm_medium=article3" rel="noopener noreferrer"&gt;complete guide to the PDF toolkit&lt;/a&gt; goes deeper on when to merge vs split vs compress — with the same no-upload guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is hard in the browser (an honest list)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted PDFs&lt;/strong&gt;: pdf-lib supports loading some variants but not decrypting all of them; the correct UX is a clear error, not a partial file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR&lt;/strong&gt;: converting scanned pages back to selectable text needs a heavy model; we shipped without it rather than fake it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Very large merges&lt;/strong&gt;: a few hundred pages works; ten thousand stresses mobile Safari memory — structural merging is cheap, but the file still has to exist in RAM once&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are server-shaped problems pretending to be local. They are the honest boundaries of the approach, and knowing them is the difference between a local tool and a local toy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is post #3 in the local-first series: &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;#1 why we built 166 no-upload tools&lt;/a&gt;, &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-inclu"&gt;#2 browser-side AI segmentation&lt;/a&gt;. All 166 tools live at &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article3" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Running AI Portrait Segmentation Entirely in the Browser — Bugs Included</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Sun, 20 Sep 2026 04:06:40 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-included-4ioi</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-included-4ioi</guid>
      <description>&lt;p&gt;My &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;first post here&lt;/a&gt; mentioned that our ID-photo tools run portrait segmentation locally — a passport photo never leaves your machine. A few readers asked how that actually works, so here is the deep dive, including the three bugs that nearly shipped to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack is deliberately boring
&lt;/h2&gt;

&lt;p&gt;MediaPipe Selfie Segmentation, compiled to WASM, loading a &lt;strong&gt;244KB&lt;/strong&gt; tflite model from our own origin (no CDN, no Google API key, works offline once the PWA is installed). The pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File → createImageBitmap → &amp;lt;canvas&amp;gt; → segmenter.segment(canvas)
     → categoryMask (Uint8Array) → custom mask pass → composite → download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything after the file picker is client-side JavaScript. The "server" is nginx serving static files — there is nothing to send data &lt;em&gt;to&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: The mask was inverted, and the specs were no help
&lt;/h2&gt;

&lt;p&gt;The segmentation call returns a &lt;code&gt;categoryMask&lt;/code&gt;. Intuition says: non-zero pixels = person, zero = background. &lt;strong&gt;MediaPipe's convention is the opposite: 0 is the person.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We shipped the intuitive version. The result: users uploaded a portrait, picked "blue background", and got a photo where the &lt;em&gt;person&lt;/em&gt; was painted solid blue and the background stayed untouched. The bug report was a screenshot of a smurf. &lt;/p&gt;

&lt;p&gt;The fix is one line — invert the predicate — but the lesson stuck: when a library hands you an opaque byte array, write a 10-line visualization dump before building anything on top of it. We now render the raw mask as grayscale before every mask-processing change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: The 1-pixel white halo that took three rounds to kill
&lt;/h2&gt;

&lt;p&gt;After recoloring, every hair edge showed a white fringe. Classic cause: the mask boundary is a hard 0/1 cut, but anti-aliased pixels at the boundary are half-person, half-background.&lt;/p&gt;

&lt;p&gt;Round 1: dilate the person region by 1px. Better, halo narrower.&lt;br&gt;
Round 2: 2px. Better still, but now ears looked slightly "eaten".&lt;br&gt;
Round 3 — the actual fix was two changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dilate the background side by 3px&lt;/strong&gt; (paint background color slightly &lt;em&gt;into&lt;/em&gt; the person), and&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remove the feathering entirely.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second one is counter-intuitive. We had added edge feathering (alpha-blend the mask boundary) for softness — but feathering blends the new background color at reduced opacity, which is exactly what a translucent white-ish fringe is. Hard edges beat pretty edges here; hair detail survives better with a slightly aggressive dilate than with a soft, diluted boundary.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug #3: Safari and &lt;code&gt;createImageBitmap&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;createImageBitmap(file)&lt;/code&gt; — the clean, efficient entry point — is unavailable in older Safari. The fallback is an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element with &lt;code&gt;objectURL&lt;/code&gt;, awaited through &lt;code&gt;img.decode()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;createImageBitmap&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;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="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rej&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;img&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;Image&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;onload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;res&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;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rej&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&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;Both paths end in the same &lt;code&gt;canvas.drawImage&lt;/code&gt;, so downstream code never knows the difference. Feature-detect at the edge, normalize immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "local AI" buys you in practice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A passport photo with face, ID number, and biometric pattern never touches a server — not because of a policy, but because &lt;strong&gt;there is no upload code path at all&lt;/strong&gt;. F12 → Network proves it live.&lt;/li&gt;
&lt;li&gt;It works on a plane, behind the great firewall, and on a locked-down corp machine.&lt;/li&gt;
&lt;li&gt;Model inference at ~100-300ms per photo on a mid-range phone. Users cannot tell it is not a server call.&lt;/li&gt;
&lt;li&gt;Our "server" cost for this feature is 244KB of static bandwidth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this requires a special product. It requires rejecting the default architecture where "AI feature" means "API call".&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The ID-photo background changer is live (pick any color, standard sizes for Chinese/US/visa formats, print layouts):&lt;br&gt;
&lt;a href="https://www.wcytcn.com/tools/id-photo-background?utm_source=devto&amp;amp;utm_medium=article2" rel="noopener noreferrer"&gt;ToolVault ID Photo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are building client-side ML: what's the weirdest silent failure you have debugged in a byte array? That mask inversion took us a user screenshot to find — I am curious what yours took.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More local-first tools: &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article2" rel="noopener noreferrer"&gt;166 tools, zero uploads&lt;/a&gt;. First post in this series: &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;why we built it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built 166 Developer Tools That Never Send Your Files to a Server</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Tue, 15 Sep 2026 06:41:06 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7</guid>
      <description>&lt;p&gt;Every time I needed to convert a HEIC photo, merge two PDFs, or resize an ID photo, the search results led to the same kind of site: "Upload your file here." A passport photo. A contract PDF. A spreadsheet with real data. All shipped off to some unknown server, processed, and hopefully deleted.&lt;/p&gt;

&lt;p&gt;I wanted the opposite. So I built &lt;a href="https://www.wcytcn.com/?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; — 166 developer and productivity tools where &lt;strong&gt;every computation runs in your browser and zero bytes of your input leave your machine&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data conversion&lt;/strong&gt;: JSON ↔ YAML ↔ XML ↔ CSV ↔ Excel, Excel → Word, Markdown → Docx&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image tools&lt;/strong&gt;: HEIC/WebP/AVIF/SVG conversions, batch resize, images → PDF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF suite&lt;/strong&gt;: merge, split, PDF → JPG, compress — all via pdf-lib, all client-side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ID photo tools&lt;/strong&gt;: background replacement, standard sizes, print layouts. Portrait segmentation runs a 244KB MediaPipe tflite model &lt;strong&gt;in the browser&lt;/strong&gt; — your photo never touches a server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev staples&lt;/strong&gt;: JWT decoder, regex tester, Cron parser, CIDR calculator, hashing, AES/RSA encryption, diff, code formatters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflows&lt;/strong&gt;: chain multiple tools into a pipeline, so you stop copy-pasting between tabs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture is the privacy policy
&lt;/h2&gt;

&lt;p&gt;Most "online tools" are thin clients over a backend conversion service. ToolVault is statically exported Next.js — 998 pre-rendered pages served as plain files. There is no backend. No database. Nothing to leak, nothing to breach, nothing to subpoena.&lt;/p&gt;

&lt;p&gt;That sounds like a slogan, so here's how to verify it yourself: open any tool, hit F12, watch the Network panel while you use it. Upload bytes: &lt;strong&gt;0&lt;/strong&gt;. Not "we promise" — observable.&lt;/p&gt;

&lt;p&gt;A few implementation notes that made this work at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Library choices matter.&lt;/strong&gt; Everything runs on battle-tested client-side libraries: pdf-lib, pdf.js, SheetJS, heic2any, jszip, marked. The hard part is composing them (e.g., Safari's &lt;code&gt;createImageBitmap&lt;/code&gt; gaps, which needed an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; decode fallback).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline is a feature, not a gimmick.&lt;/strong&gt; It's an installable PWA. Install it once, disconnect your network, and all 166 tools keep working. "Works on a plane" is the honest privacy demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segmentation without the cloud.&lt;/strong&gt; The ID-photo background remover runs MediaPipe Selfie Segmentation locally via WASM. The trickiest bug wasn't the model — it was realizing the category mask semantics were inverted from what I assumed (0 = person, not background), and a 3px background dilation needed to kill halo artifacts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this matters more than it seems
&lt;/h2&gt;

&lt;p&gt;When a tool uploads your file, you're making a trust decision — about their security, their employees, their acquisition by someone worse. When the computation is local, there is no trust decision. The browser sandbox &lt;em&gt;is&lt;/em&gt; the policy.&lt;/p&gt;

&lt;p&gt;This is also where tool sites are heading: WebAssembly and mature client-side libraries have removed most reasons for a conversion backend to exist. The remaining reason is usually ad-driven engagement ("watch our progress bar while we upload").&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;👉 &lt;a href="https://www.wcytcn.com/?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;https://www.wcytcn.com&lt;/a&gt; — no signup, no login, no upload. Your bookmarks are the account system.&lt;/p&gt;

&lt;p&gt;If you have a tool you use weekly that doesn't have a "never uploads" version, tell me in the comments — that's my backlog.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>privacy</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
