<?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: Mohan</title>
    <description>The latest articles on DEV Community by Mohan (@mohanvenkatakrishnan).</description>
    <link>https://dev.to/mohanvenkatakrishnan</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%2F179195%2F20f3a01f-4b80-4502-8f5d-e3c4d61f388f.JPG</url>
      <title>DEV Community: Mohan</title>
      <link>https://dev.to/mohanvenkatakrishnan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohanvenkatakrishnan"/>
    <language>en</language>
    <item>
      <title>Introducing CommentIQ: On-Device AI for YouTube Comment Analysis</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Sun, 05 Jul 2026 09:00:27 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/building-commentiq-on-device-ai-for-youtube-comment-analysis-23lo</link>
      <guid>https://dev.to/mohanvenkatakrishnan/building-commentiq-on-device-ai-for-youtube-comment-analysis-23lo</guid>
      <description>&lt;p&gt;CommentIQ is a Chrome extension that reads the comments section of any YouTube video and runs sentiment analysis, topic clustering, and question extraction — entirely on your device using Gemini Nano. No API calls, no backend, nothing leaves the browser. This is a writeup of the parts that actually gave us trouble.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting comments without an API
&lt;/h2&gt;

&lt;p&gt;YouTube killed public API access for comments years ago. That means the only way to read them is from the page itself.&lt;/p&gt;

&lt;p&gt;YouTube is built on Polymer with custom elements — &lt;code&gt;ytd-comment-renderer&lt;/code&gt;, &lt;code&gt;ytd-comment-thread-renderer&lt;/code&gt; and friends. They're not plain HTML. The text you want sits inside a &lt;code&gt;#content-text&lt;/code&gt; element nested several shadow-DOM layers deep in each renderer. Querying for it is straightforward once you know the selector; the hard part is &lt;em&gt;when&lt;/em&gt; to query.&lt;/p&gt;

&lt;p&gt;Comments don't exist in the DOM when the page loads. YouTube lazy-loads them as the user scrolls toward the bottom of the page. We initially tried waiting for a fixed delay after navigation — fragile, obviously — before switching to a &lt;code&gt;MutationObserver&lt;/code&gt; watching &lt;code&gt;ytd-comments&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;observer&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;MutationObserver&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;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ytd-comment-thread-renderer&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;threads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;extractAndAnalyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;childList&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="na"&gt;subtree&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;p&gt;This worked until we hit YouTube's intersection-observer-based lazy render: threads exist in the DOM but their inner content isn't populated until they scroll into view. We ended up triggering a programmatic scroll to force the first batch to render, then immediately scrolling back. Hacky, but reliable.&lt;/p&gt;




&lt;h2&gt;
  
  
  YouTube is a SPA and it will surprise you
&lt;/h2&gt;

&lt;p&gt;The bigger navigation problem: YouTube never does a full page load between videos. It's a single-page app using the History API. Your content script loads once per tab and keeps running as the user clicks from video to video.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chrome.webNavigation.onHistoryStateUpdated&lt;/code&gt; fires for each video navigation, but it fires &lt;em&gt;before&lt;/em&gt; the new page's DOM is ready. We had to debounce and re-run the observer setup on each navigation event, making sure to cancel any in-flight analysis from the previous video first.&lt;/p&gt;

&lt;p&gt;We also ran into the extension popup losing its connection to the content script mid-session. The pattern that worked: keep a &lt;code&gt;port&lt;/code&gt; open between popup and content script, detect &lt;code&gt;onDisconnect&lt;/code&gt;, and show a "reload the page" nudge rather than silently failing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feeding Gemini Nano without hitting the context limit
&lt;/h2&gt;

&lt;p&gt;Chrome's built-in Prompt API (via &lt;code&gt;window.ai.languageModel&lt;/code&gt;) has a context window. A popular video can have thousands of comments; sending all of them at once overflows it immediately.&lt;/p&gt;

&lt;p&gt;We first check token capacity before each request:&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;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;languageModel&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokensLeft&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we batch comments into chunks that each fit comfortably under the limit, run analysis on each chunk, and merge results. For sentiment this means averaging scores across chunks. For topic clustering we run a second summarisation pass over the chunk outputs rather than the raw comments.&lt;/p&gt;

&lt;p&gt;The model also needs to be downloaded before first use. &lt;code&gt;window.ai.languageModel.capabilities()&lt;/code&gt; returns &lt;code&gt;{ available: 'readily' | 'after-download' | 'no' }&lt;/code&gt;. When it's &lt;code&gt;after-download&lt;/code&gt; we show a progress indicator and poll until it flips to &lt;code&gt;readily&lt;/code&gt;. When it's &lt;code&gt;no&lt;/code&gt; — typically because the device doesn't meet the hardware requirements — we surface a clear message rather than a silent failure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Structuring the output
&lt;/h2&gt;

&lt;p&gt;Raw Gemini Nano output isn't JSON. We prompt for structured output explicitly and then parse it, with a fallback for when the model goes off-script:&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;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
Analyze these YouTube comments and return ONLY valid JSON with this shape:
{ "sentiment": { "positive": 0-100, "neutral": 0-100, "negative": 0-100 },
  "topics": ["string", ...],
  "questions": ["string", ...],
  "ideas": ["string", ...] }

Comments:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;batch&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&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="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fallbackParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawOutput&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 fallback uses regex to pull numbers and lists out of whatever text the model returned. It covers about 95% of the cases where strict JSON parsing fails.&lt;/p&gt;




&lt;h2&gt;
  
  
  Shipping without a backend
&lt;/h2&gt;

&lt;p&gt;Like &lt;a href="https://dev.to/mohanvenkatakrishnan/building-margin-a-privacy-first-news-reader-inside-chromes-side-panel-32c"&gt;Margin&lt;/a&gt;, we wanted zero server infrastructure. Everything is stored in &lt;code&gt;chrome.storage.local&lt;/code&gt; — analysis results are cached per video ID so re-opening the extension on the same video is instant.&lt;/p&gt;

&lt;p&gt;The only outbound request CommentIQ ever makes is to Lemon Squeezy's public License API for key validation on the Pro tier. No secret is embedded in the extension; the API is designed to be called client-side. The license check runs once at activation and the result is cached locally — subsequent launches don't hit the network at all.&lt;/p&gt;

&lt;p&gt;For the Chrome Web Store review we had to document this data flow explicitly. The reviewers flagged the &lt;code&gt;activeTab&lt;/code&gt; and &lt;code&gt;scripting&lt;/code&gt; permissions as potentially broad; the response was a clear explanation in the privacy policy that we only read from &lt;code&gt;ytd-comment-renderer&lt;/code&gt; elements on &lt;code&gt;youtube.com/*&lt;/code&gt; and never transmit that data.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd do differently
&lt;/h2&gt;

&lt;p&gt;The multi-pass chunking adds latency that's noticeable on videos with dense comment sections. A streaming UI — showing results as each chunk completes rather than waiting for the full merge — would hide this better. It's on the list.&lt;/p&gt;

&lt;p&gt;The other thing: we underestimated how much YouTube's DOM changes between A/B test variants. The comment container selector broke twice during development because YouTube was testing different layouts. A more defensive selector strategy with fallbacks would save debugging time.&lt;/p&gt;




&lt;p&gt;CommentIQ is &lt;a href="https://chromewebstore.google.com/detail/commentiq/cmkehjfedgcjnpjojlanlgalojnlcmjf" rel="noopener noreferrer"&gt;live on the Chrome Web Store&lt;/a&gt; — free to install, no account required. Questions or feedback welcome in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building tapdot tools: 90+ single-purpose utilities, zero backends, zero accounts</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Sat, 04 Jul 2026 18:40:21 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/building-tapdot-tools-90-single-purpose-utilities-zero-backends-zero-accounts-1812</link>
      <guid>https://dev.to/mohanvenkatakrishnan/building-tapdot-tools-90-single-purpose-utilities-zero-backends-zero-accounts-1812</guid>
      <description>&lt;p&gt;I got tired of counting words for a blog draft on a site that wanted to email me a "productivity report" afterward. Then I needed to format some JSON and landed on a tool wrapped in ad networks with a cookie banner that took longer to dismiss than the formatting took to run. That's the origin story: every small utility I needed lived on a site that wanted something back — an email, a tracking pixel, an account "to save my history."&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://tools.tapdot.org" rel="noopener noreferrer"&gt;tapdot tools&lt;/a&gt; — 92 tools now, one job each, no backend, no build step, no accounts, nothing you type ever sent anywhere unless the page says otherwise in plain English. This is the build log.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pain point, specifically
&lt;/h3&gt;

&lt;p&gt;A JSON formatter is maybe 40 lines of JS — building one isn't hard. The problem is the business model around free web utilities: it's almost always ads or data, because a one-off tool has no other way to pay for itself. You end up on a fragmented web where every tool lives on a different domain with a different privacy policy, half of which you didn't read.&lt;/p&gt;

&lt;p&gt;The fix isn't a better tool. It's removing the reason to ever ask the question. If nothing leaves the browser, there's no policy to worry about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Static, browser-only, no exceptions without a receipt
&lt;/h3&gt;

&lt;p&gt;Every tool is plain HTML/CSS/JS in its own folder — no React, no bundler, no build step. That's not a performance flex, it's a trust mechanism: you can view-source any tool and see exactly what runs, because what runs &lt;em&gt;is&lt;/em&gt; the source.&lt;/p&gt;

&lt;p&gt;The rule I hold hardest: any tool that talks to the network has to disclose it, specifically, in the privacy policy. Right now that's two exceptions out of 92 tools — an optional URL-lookup proxy for one citation tool, and a currency exchange-rate fetch (rates only, never your amounts). Everything else is closed off from the network entirely by construction, not by promise. I did prototype a dictation tool using the browser's speech recognition API, but killed it before shipping — more on why below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making 92 static pages feel like one app, without a router
&lt;/h3&gt;

&lt;p&gt;The hard part of "no backend, no framework" is that plain multi-page sites feel like the '90s — full reload, white flash, lost scroll position. Two browser features did the whole job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@view-transition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;navigation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That animates the swap between pages natively — no client-side router, no virtual DOM. Pair it with Speculation Rules so hovering a link prerenders the destination in the background before you even click:&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;specScript&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;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;specScript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speculationrules&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;specScript&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;prerender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;href_matches&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="na"&gt;eagerness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;moderate&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&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;specScript&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time your cursor lands on a link, the next page is often already rendered. It feels instant — two browser primitives, not a framework.&lt;/p&gt;

&lt;h3&gt;
  
  
  The CSS bug that kept coming back
&lt;/h3&gt;

&lt;p&gt;This one bit me four separate times before it clicked. Cards would overflow on mobile even though every child inside was already told to shrink. Turns out CSS Grid and Flexbox children have an implicit &lt;code&gt;min-size: auto&lt;/code&gt; — they won't shrink below their content's intrinsic width unless you override it, and that has to happen at &lt;em&gt;every&lt;/em&gt; level of nesting, not just the outer container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.ts-select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.ts-workbench&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.dev-row&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&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;Fix one nested grid, ship a new tool with a nested grid three weeks later, hit the exact same overflow again. It's now a standing rule in the project: any new nested grid/flex layout gets &lt;code&gt;min-width: 0&lt;/code&gt; at every level, on sight, because I got tired of rediscovering the same CSS spec behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real crypto, not remembered crypto
&lt;/h3&gt;

&lt;p&gt;When I added a password-hashing tool, the tempting shortcut was writing bcrypt from memory. I didn't. Hand-transcribing Blowfish S-boxes or an Argon2 mixing function from memory, in a tool whose whole job is producing &lt;em&gt;correct&lt;/em&gt; hashes, is exactly where "roughly remember" becomes a silent bug nobody catches until it matters. Instead I vendored &lt;code&gt;hash-wasm&lt;/code&gt;'s actual WASM builds of the reference C implementations — self-contained, embedded as base64, no runtime fetch — and wrote round-trip tests (hash → verify → reject-wrong-password) before trusting it. Local-only and "don't write your own crypto" pulled in the same direction here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gating on-device AI without lying about it
&lt;/h3&gt;

&lt;p&gt;A few tools use Chrome's built-in on-device model APIs — genuinely on-device, no server round-trip. But "on-device AI" fails in more states than "works" or "doesn't": model not downloaded, unsupported GPU, wrong browser. I wanted to avoid a dead button with a spinner.&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;checkSupport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Writer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&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;Not available in this browser&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="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;avail&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;Writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;availability&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;avail&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&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="s2"&gt;This device can't run the model&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="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avail&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;available&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;Ready&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;Will download on first use&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 rule that came out of this: only call &lt;code&gt;availability()&lt;/code&gt; passively on load. Never spin up a real session just to check if you &lt;em&gt;can&lt;/em&gt; — that's what actually triggers the download, so it has to happen on a genuine click, not a background probe.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tool I killed on principle
&lt;/h3&gt;

&lt;p&gt;I built a dictation tool using the browser's built-in speech recognition API and got it fully working — live transcript, punctuation voice commands, the works. Then I pulled it before shipping. Chrome's &lt;code&gt;SpeechRecognition&lt;/code&gt; sends your microphone audio to Google's speech-to-text servers to produce the transcript — there's no way around that with today's Web Speech API. I tried making the exception loud instead of quiet: a callout above the tool, a dedicated privacy-policy section, footer text spelling out exactly what happens. It still didn't sit right. If "nothing leaves your device" has an asterisk, it's not really the promise anymore, disclosure or not — so the tool didn't ship. Better to have 92 tools that all mean the same thing than 93 where one needs a footnote.&lt;/p&gt;

&lt;h3&gt;
  
  
  The full list — all 92 tools, by collection
&lt;/h3&gt;

&lt;p&gt;Everything below runs at &lt;a href="https://tools.tapdot.org" rel="noopener noreferrer"&gt;tools.tapdot.org&lt;/a&gt; — click any name to open it directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  📚 Study (4)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/study/cite/" rel="noopener noreferrer"&gt;CiteMaker&lt;/a&gt; — APA, MLA, Chicago, Harvard citations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/study/flashcards/" rel="noopener noreferrer"&gt;FlashForge&lt;/a&gt; — Flashcards with spaced repetition&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/study/grades/" rel="noopener noreferrer"&gt;GradeCalc&lt;/a&gt; — GPA, weighted grade, final exam calculator&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/study/bias/" rel="noopener noreferrer"&gt;BiasCheck&lt;/a&gt; — Media bias &amp;amp; loaded language analyser&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ✍️ Write (4)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/write/readscore/" rel="noopener noreferrer"&gt;ReadScore&lt;/a&gt; — Flesch-Kincaid readability analysis&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/write/wordcount/" rel="noopener noreferrer"&gt;WordCount Pro&lt;/a&gt; — Word count, keyword density, live&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/write/lorem/" rel="noopener noreferrer"&gt;LoremCraft&lt;/a&gt; — Placeholder text in 10 styles&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/write/thread/" rel="noopener noreferrer"&gt;ThreadCraft&lt;/a&gt; — Split long text into a tweet thread&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🛠️ Dev (27)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/json/" rel="noopener noreferrer"&gt;JSONLab&lt;/a&gt; — Format, validate, minify, diff JSON&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/jsonconvert/" rel="noopener noreferrer"&gt;JSONConvert&lt;/a&gt; — JSON to YAML, CSV, XML, TOML&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/jwt/" rel="noopener noreferrer"&gt;JWTStudio&lt;/a&gt; — Decode &amp;amp; inspect JWT tokens&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/yaml/" rel="noopener noreferrer"&gt;YAMLCheck&lt;/a&gt; — YAML validator &amp;amp; formatter&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/csv/" rel="noopener noreferrer"&gt;CSVExplore&lt;/a&gt; — Sortable, filterable CSV viewer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/markdown/" rel="noopener noreferrer"&gt;MarkdownLive&lt;/a&gt; — Markdown editor with live preview&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/html/" rel="noopener noreferrer"&gt;HTMLPreview&lt;/a&gt; — Sandboxed HTML renderer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/sql/" rel="noopener noreferrer"&gt;SQLFormat&lt;/a&gt; — SQL formatter &amp;amp; beautifier&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/contrast/" rel="noopener noreferrer"&gt;ColourContrast&lt;/a&gt; — WCAG AA/AAA contrast checker&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/uuid/" rel="noopener noreferrer"&gt;UUIDGen&lt;/a&gt; — UUID v4/v7, ULID, nanoid generator&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/timezone/" rel="noopener noreferrer"&gt;TimezoneNow&lt;/a&gt; — World clock &amp;amp; meeting overlap finder&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/timeconvert/" rel="noopener noreferrer"&gt;TZConvert&lt;/a&gt; — Convert a date &amp;amp; time across timezones&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/regex/" rel="noopener noreferrer"&gt;RegexLab&lt;/a&gt; — Regex tester with match highlighting&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/cron/" rel="noopener noreferrer"&gt;CronLab&lt;/a&gt; — Cron expression workbench&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/base64/" rel="noopener noreferrer"&gt;Base64Tool&lt;/a&gt; — Encode and decode Base64 live — standard or URL-safe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/diff/" rel="noopener noreferrer"&gt;DiffCheck&lt;/a&gt; — Line-by-line text comparison, fully local&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/websocket/" rel="noopener noreferrer"&gt;WSTester&lt;/a&gt; — Connect and test WebSocket servers from your browser&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/play/" rel="noopener noreferrer"&gt;CodePlay&lt;/a&gt; — HTML/CSS/JS playground with live preview and console&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/bigo/" rel="noopener noreferrer"&gt;BigOCheck&lt;/a&gt; — Estimate time complexity from code structure&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/worldclock/" rel="noopener noreferrer"&gt;WorldClock&lt;/a&gt; — Airport-style analog world clocks, TV fullscreen&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/timestamp/" rel="noopener noreferrer"&gt;TimestampConvert&lt;/a&gt; — Unix timestamp &amp;lt;-&amp;gt; date, live, across timezones&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/jsoncsv/" rel="noopener noreferrer"&gt;JSONCSV&lt;/a&gt; — Convert JSON arrays to CSV and back&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/hashgen/" rel="noopener noreferrer"&gt;HashGen&lt;/a&gt; — SHA-1/256/384/512 hashes for text or files&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/keygen/" rel="noopener noreferrer"&gt;KeyGen&lt;/a&gt; — RSA/ECDSA keypair generator, exported as PEM&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/passhash/" rel="noopener noreferrer"&gt;PassHash&lt;/a&gt; — Hash and verify passwords with real bcrypt/Argon2id (WASM)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/svgclean/" rel="noopener noreferrer"&gt;SVGClean&lt;/a&gt; — Strip editor cruft, comments, and excess precision from SVG markup&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/dev/sqlobfuscate/" rel="noopener noreferrer"&gt;SQLObfuscate&lt;/a&gt; — Anonymize table/column names and literals in a SQL query&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  📣 Marketing (8)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/utm/" rel="noopener noreferrer"&gt;UTMBuilder&lt;/a&gt; — Build UTM tracking links — history saved locally&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/headline/" rel="noopener noreferrer"&gt;HeadlineScore&lt;/a&gt; — Score headlines for clarity, emotion, and SEO&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/emailsubject/" rel="noopener noreferrer"&gt;EmailSubjectTester&lt;/a&gt; — Spam triggers, mobile preview, sentiment&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/adcopy/" rel="noopener noreferrer"&gt;AdCopyWriter&lt;/a&gt; — Google, Meta and LinkedIn ad copy from a brief&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/calendar/" rel="noopener noreferrer"&gt;SocialCalendar&lt;/a&gt; — Plan your content calendar — saved locally&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/persona/" rel="noopener noreferrer"&gt;PersonaBuilder&lt;/a&gt; — AI-assisted customer personas, saved locally&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/competitor/" rel="noopener noreferrer"&gt;CompetitorMatrix&lt;/a&gt; — Feature comparison matrix — export CSV or Markdown&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/marketing/roi/" rel="noopener noreferrer"&gt;ROICalculator&lt;/a&gt; — ROI, ROAS, CPA, CLV and payback period&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  💰 Finance (11)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/compound/" rel="noopener noreferrer"&gt;CompoundCalc&lt;/a&gt; — Compound interest with monthly contributions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/budget/" rel="noopener noreferrer"&gt;BudgetPlanner&lt;/a&gt; — 50/30/20 budget split with a donut chart&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/mortgage/" rel="noopener noreferrer"&gt;MortgageCalc&lt;/a&gt; — EMI, amortisation schedule, overpayment savings&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/investments/" rel="noopener noreferrer"&gt;InvestmentTracker&lt;/a&gt; — Portfolio holdings, returns, and allocation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/tax/" rel="noopener noreferrer"&gt;TaxEstimate&lt;/a&gt; — Income tax for India, US or UK&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/currency/" rel="noopener noreferrer"&gt;CurrencyConvert&lt;/a&gt; — 170+ currencies — rates cached daily&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/equity/" rel="noopener noreferrer"&gt;EquityCalc&lt;/a&gt; — Cap table dilution across funding rounds&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/networth/" rel="noopener noreferrer"&gt;NetWorthTracker&lt;/a&gt; — Assets minus liabilities, tracked monthly&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/loan/" rel="noopener noreferrer"&gt;LoanCalc&lt;/a&gt; — EMI, amortisation, and part-payment impact for any loan&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/retire/" rel="noopener noreferrer"&gt;RetireCalc&lt;/a&gt; — Retirement corpus needed vs on track — and the gap&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/finance/inflation/" rel="noopener noreferrer"&gt;InflationCalc&lt;/a&gt; — What inflation does to your money over time&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⚖️ Legal (6)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/legal/contract/" rel="noopener noreferrer"&gt;ContractRead&lt;/a&gt; — Plain-English contract summary — on-device AI&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/legal/nda/" rel="noopener noreferrer"&gt;NDAGenerator&lt;/a&gt; — Mutual or one-way NDA from a template&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/legal/privacy-policy/" rel="noopener noreferrer"&gt;PrivacyPolicyGen&lt;/a&gt; — GDPR/CCPA-aware privacy policy generator&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/legal/terms/" rel="noopener noreferrer"&gt;TermsBuilder&lt;/a&gt; — Terms of Service from a template&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/legal/copyright/" rel="noopener noreferrer"&gt;CopyrightChecker&lt;/a&gt; — Estimate public-domain status by jurisdiction&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/legal/glossary/" rel="noopener noreferrer"&gt;LegalGlossary&lt;/a&gt; — 130+ legal terms explained in plain English&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🧑‍💼 HR (6)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/hr/salary/" rel="noopener noreferrer"&gt;SalaryBand&lt;/a&gt; — Pay grades, compa-ratio, range visualisation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/hr/jd/" rel="noopener noreferrer"&gt;JobDescriptionWriter&lt;/a&gt; — Inclusive job descriptions via on-device AI&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/hr/interview/" rel="noopener noreferrer"&gt;InterviewKit&lt;/a&gt; — Role-specific questions and a scoring rubric&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/hr/offer/" rel="noopener noreferrer"&gt;OfferLetterBuilder&lt;/a&gt; — Professional offer letter from a template&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/hr/onboarding/" rel="noopener noreferrer"&gt;OnboardingChecklist&lt;/a&gt; — Day 1 / Week 1 / Month 1 checklist, saved locally&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/hr/leave/" rel="noopener noreferrer"&gt;LeaveCalculator&lt;/a&gt; — Accrual, balance, and carry-forward&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🩺 Health (6)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/health/bmi/" rel="noopener noreferrer"&gt;BMICalc&lt;/a&gt; — BMI, BMR, TDEE and healthy weight range&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/health/medication/" rel="noopener noreferrer"&gt;MedicationLog&lt;/a&gt; — Medications, doses and adherence rate&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/health/symptoms/" rel="noopener noreferrer"&gt;SymptomDiary&lt;/a&gt; — Log symptoms and severity on a calendar&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/health/cycle/" rel="noopener noreferrer"&gt;CycleTracker&lt;/a&gt; — Predict next period and fertile window&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/health/water/" rel="noopener noreferrer"&gt;WaterIntake&lt;/a&gt; — One-tap hydration logging and streaks&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/health/sleep/" rel="noopener noreferrer"&gt;SleepLog&lt;/a&gt; — Sleep duration, quality and AI pattern analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎨 Design (9)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/sketch/" rel="noopener noreferrer"&gt;SketchPad&lt;/a&gt; — Local whiteboard — pen, shapes, arrows, text, PNG export&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/photo/" rel="noopener noreferrer"&gt;PhotoTune&lt;/a&gt; — Local photo editor — filters, rotate, resize, export&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/palette/" rel="noopener noreferrer"&gt;PaletteForge&lt;/a&gt; — Colour palettes with CSS/Tailwind export and contrast ratios&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/typography/" rel="noopener noreferrer"&gt;TypographyScale&lt;/a&gt; — A harmonious 9-step type scale from base size and ratio&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/icons/" rel="noopener noreferrer"&gt;IconExplorer&lt;/a&gt; — A curated outline icon set — copy as SVG, JSX, or Vue&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/shadows/" rel="noopener noreferrer"&gt;ShadowStudio&lt;/a&gt; — Layered CSS box shadows visually, with presets&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/spacing/" rel="noopener noreferrer"&gt;SpacingCalc&lt;/a&gt; — Linear or multiplicative spacing scale, with export&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/gradient/" rel="noopener noreferrer"&gt;GradientMaker&lt;/a&gt; — Linear, radial, and conic CSS gradients visually&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/design/imagecompress/" rel="noopener noreferrer"&gt;ImageCompress&lt;/a&gt; — Resize and recompress images locally — JPEG/WebP/PNG&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ✅ Productivity (7)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/productivity/convert/" rel="noopener noreferrer"&gt;UnitConvert&lt;/a&gt; — Every common unit converted live, with full tables&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/productivity/focus/" rel="noopener noreferrer"&gt;FocusTimer&lt;/a&gt; — Pomodoro-style focus sessions with notifications&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/productivity/note/" rel="noopener noreferrer"&gt;QuickNote&lt;/a&gt; — A fast, private notepad that autosaves as you type&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/productivity/decision/" rel="noopener noreferrer"&gt;DecisionMatrix&lt;/a&gt; — Weigh criteria, rate options, see which wins&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/productivity/meeting-timer/" rel="noopener noreferrer"&gt;MeetingTimer&lt;/a&gt; — See the real cost of your meeting, live&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/productivity/habits/" rel="noopener noreferrer"&gt;HabitTracker&lt;/a&gt; — Daily habits with streaks and a heatmap&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/productivity/reading/" rel="noopener noreferrer"&gt;ReadingList&lt;/a&gt; — Save articles and books, track status and notes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🤖 Chrome AI (4)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/ai/summarize/" rel="noopener noreferrer"&gt;AISummarize&lt;/a&gt; — Summarize long text on-device — TL;DR, key points, headline&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/ai/translate/" rel="noopener noreferrer"&gt;AITranslate&lt;/a&gt; — Translate between languages on-device with auto-detection&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/ai/write/" rel="noopener noreferrer"&gt;AIWrite&lt;/a&gt; — Draft an email, post, or message from bullet points — on-device&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tools.tapdot.org/ai/rewrite/" rel="noopener noreferrer"&gt;AIRewrite&lt;/a&gt; — Change the tone or length of text — on-device&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What's next
&lt;/h3&gt;

&lt;p&gt;The repo's public: &lt;a href="https://github.com/mohan-venkatakrishnan/tapdot-tools" rel="noopener noreferrer"&gt;github.com/mohan-venkatakrishnan/tapdot-tools&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;I'd genuinely like feedback on where I drew the line with the dictation tool — was killing it the right call, or is a loud, honest disclosure enough to justify keeping a tool that isn't fully local? Curious how others building free-but-honest tools have drawn that line.&lt;/p&gt;

&lt;p&gt;And if there's some small utility you keep reaching for that always comes bundled with ads, logins, or a privacy policy you'd rather not read — tell me in the comments. If it can be done fully client-side, I'll build it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>privacy</category>
      <category>showdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Building Margin: A Privacy-First News Reader Inside Chrome's Side Panel</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Sun, 21 Jun 2026 18:42:21 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/building-margin-a-privacy-first-news-reader-inside-chromes-side-panel-32c</link>
      <guid>https://dev.to/mohanvenkatakrishnan/building-margin-a-privacy-first-news-reader-inside-chromes-side-panel-32c</guid>
      <description>&lt;p&gt;I built a Chrome extension called &lt;strong&gt;Margin&lt;/strong&gt; — a news reader that lives in&lt;br&gt;
the browser's side panel and shows one bite-sized story at a time, instead of&lt;br&gt;
an infinite-scroll feed. This is a build log: the decisions, the constraints&lt;br&gt;
that pushed back, and a couple of things I had to solve in slightly unusual&lt;br&gt;
ways.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the side panel
&lt;/h2&gt;

&lt;p&gt;Chrome shipped &lt;code&gt;chrome.sidePanel&lt;/code&gt; in MV3 a while back and most uses I saw were&lt;br&gt;
utility tools — note-taking, translation helpers. Nobody was using it for&lt;br&gt;
content consumption. News felt like a good fit: a side panel that stays open&lt;br&gt;
next to whatever you're working on, where you tap through headlines in a&lt;br&gt;
couple of minutes without leaving the page.&lt;/p&gt;

&lt;p&gt;The reading model is intentionally narrow: one card, one headline, one short&lt;br&gt;
summary, tap to read the full article &lt;em&gt;at the source&lt;/em&gt;. No infinite scroll, no&lt;br&gt;
algorithmic feed. If you've used InShorts, the shape will be familiar.&lt;/p&gt;
&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Preact + Vite + &lt;code&gt;@crxjs/vite-plugin&lt;/code&gt;. Preact because the side panel is a small&lt;br&gt;
UI surface and I didn't want React's weight for what's essentially a card&lt;br&gt;
stack and a settings screen. &lt;code&gt;@crxjs/vite-plugin&lt;/code&gt; handles the MV3-specific&lt;br&gt;
build wiring (manifest generation, service worker loader, HMR for the&lt;br&gt;
extension context) that would otherwise be a lot of manual plumbing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The constraint that shaped onboarding
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;chrome.sidePanel.open()&lt;/code&gt; &lt;strong&gt;requires a user gesture&lt;/strong&gt;. You cannot call it from&lt;br&gt;
a background service worker on install — Chrome will throw. That one&lt;br&gt;
constraint shaped the whole first-run experience.&lt;/p&gt;

&lt;p&gt;My first instinct was "just auto-open the panel on install so people see it&lt;br&gt;
immediately." Doesn't work. The fix ended up being two-pronged:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On &lt;code&gt;chrome.runtime.onInstalled&lt;/code&gt; with &lt;code&gt;reason === 'install'&lt;/code&gt;, open a real
&lt;strong&gt;browser tab&lt;/strong&gt; with a short walkthrough (find the icon → pin it → open
the panel). The button on that page calls &lt;code&gt;sidePanel.open()&lt;/code&gt; — valid,
because the click &lt;em&gt;is&lt;/em&gt; the gesture.&lt;/li&gt;
&lt;li&gt;The first time the panel itself is opened, show an in-panel welcome screen
before onboarding, nudging the user to pin the toolbar icon for one-click
access later.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onInstalled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;details&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;install&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&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="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/welcome/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="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="o"&gt;=&amp;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;Small platform detail, but it's the kind of thing that's invisible until you&lt;br&gt;
hit it, and then it reshapes a whole feature.&lt;/p&gt;

&lt;p&gt;Getting "swipe" right&lt;/p&gt;

&lt;p&gt;The card stack supports scroll-to-advance, and the obvious naive approach —&lt;br&gt;
just listen to wheel/scroll deltas — over-advances. A fast trackpad swipe can&lt;br&gt;
fire a dozen scroll events, which without debouncing skips two or three cards&lt;br&gt;
at once.&lt;/p&gt;

&lt;p&gt;I went back and forth on this. My first fix added "re-acceleration"&lt;br&gt;
detection — trying to distinguish a continued gesture from a new one based on&lt;br&gt;
velocity changes. It technically worked but was fragile and hard to reason&lt;br&gt;
about. I ended up ripping it out in favor of something much simpler: track an&lt;br&gt;
idle gap between scroll events, and treat direction reversal as a new&lt;br&gt;
gesture. One card per gesture, full stop. Less clever, far more predictable —&lt;br&gt;
and it's the version that's actually shipped.&lt;/p&gt;

&lt;p&gt;Lesson: when a heuristic needs increasingly special-cased logic to handle&lt;br&gt;
edge cases, that's usually a sign the simpler version was right and the bug&lt;br&gt;
was somewhere else.&lt;/p&gt;

&lt;p&gt;Privacy, by removing things rather than adding them&lt;/p&gt;

&lt;p&gt;Margin has no backend. There was never a backend to remove, which made the&lt;br&gt;
privacy story straightforward instead of aspirational:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RSS feeds and article pages are fetched directly from publishers, from the
user's machine.&lt;/li&gt;
&lt;li&gt;Optional AI summaries run on-device via Chrome's built-in Summarizer
(Gemini Nano) — article text never leaves the browser.&lt;/li&gt;
&lt;li&gt;Settings, cached cards, and reading stats live in chrome.storage.local.
Nothing is transmitted anywhere unless the user explicitly buys the paid
tier (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only design wrinkle: Chrome's on-device model has an eligibility/&lt;br&gt;
provisioning step, and defaulting summaries to "always on" produced errors on&lt;br&gt;
devices that didn't support it yet. Fix was a tri-state preference —&lt;br&gt;
auto | on | off — where "auto" silently checks device support before&lt;br&gt;
turning summaries on, instead of assuming.&lt;/p&gt;

&lt;p&gt;Monetization without becoming a backend&lt;/p&gt;

&lt;p&gt;For the optional Plus tier, I didn't want to stand up a server just to gate&lt;br&gt;
features. I used Lemon Squeezy as merchant of record: checkout happens on&lt;br&gt;
their hosted page, the user gets a license key by email, and the extension&lt;br&gt;
activates/validates that key against Lemon Squeezy's public License API.&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;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.lemonsqueezy.com/v1/licenses/activate&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;instance_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;margin-extension&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;No secret key required client-side, no server to run, and Margin never&lt;br&gt;
touches payment details. The whole "Plus" gate is just: is there a valid&lt;br&gt;
license key in local storage?&lt;/p&gt;

&lt;p&gt;Where it's at&lt;/p&gt;

&lt;p&gt;It's live on the Chrome Web Store — free tier covers 3 topics and a couple of&lt;br&gt;
themes, Plus unlocks the rest. I'd genuinely like feedback on the reading&lt;br&gt;
flow in particular, since "one card at a time" is a more opinionated UX&lt;br&gt;
choice than a typical feed and I want to know if it actually lands for people&lt;br&gt;
who try it.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/margin/kmiddbhoklpkheeopgoehknnkejohnhp?utm_source=item-share-cb" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2Fb-Kv3d6SMI9gHFOnD1bx7Bl-Si39hcTFZW9vGwoYblGsi_AsyCNN8IIg_tzPVJEhyUq_iwS0oXauT69mP3-ZQhH_ww%3Ds128-rj-sc0x00ffffff" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/margin/kmiddbhoklpkheeopgoehknnkejohnhp?utm_source=item-share-cb" rel="noopener noreferrer" class="c-link"&gt;
            Margin - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Bite-sized news in your side panel.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;Happy to answer questions about the side panel API, MV3 service worker&lt;br&gt;
quirks, or the licensing setup — ask away.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built a Chrome extension that shows which tab is eating your RAM (and frees it in one click)</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Thu, 18 Jun 2026 06:34:53 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/i-built-a-chrome-extension-that-shows-which-tab-is-eating-your-ram-and-frees-it-in-one-click-4fcn</link>
      <guid>https://dev.to/mohanvenkatakrishnan/i-built-a-chrome-extension-that-shows-which-tab-is-eating-your-ram-and-frees-it-in-one-click-4fcn</guid>
      <description>&lt;h2&gt;
  
  
  The problem I kept running into
&lt;/h2&gt;

&lt;p&gt;I'm a chronic tab hoarder. At any given time I've got 40–80 tabs open across two windows. Chrome's built-in Memory Saver is aggressive in the wrong ways — it hibernates tabs I'm actively referencing. And the built-in task manager is a two-step detour that still doesn't tell me which tabs I should actually close.&lt;/p&gt;

&lt;p&gt;So I built Tab Memory Manager.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per-tab memory estimates&lt;/strong&gt; — A live MB count next to every open tab. Sorted by memory usage by default. There's a live total on the toolbar icon so you always know what Chrome is consuming right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart suggestions&lt;/strong&gt; — The extension flags your biggest, stalest tabs: ones that are idle the longest and consuming the most. It never suggests your active tab, pinned tabs, tabs playing audio, or domains you've whitelisted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hibernate, don't close&lt;/strong&gt; — This was the core design decision. Hibernating frees the memory but keeps the tab alive in your strip — it reloads when you click it. Much safer than closing, especially mid-research.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bulk cleanup&lt;/strong&gt; — Select multiple tabs or hit Apply on the suggestions panel. See the total memory you'll reclaim before you commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Undo list&lt;/strong&gt; — Closed something by mistake? There's a "Recently cleaned" panel. One click to restore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tab grouping&lt;/strong&gt; — Groups all your open tabs by domain into color-coded Chrome tab groups, instantly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The interesting technical bit: memory estimates
&lt;/h2&gt;

&lt;p&gt;Chrome's stable extension API doesn't expose exact per-tab memory. The &lt;code&gt;chrome.processes&lt;/code&gt; API that does exists only on Dev and Canary builds — not the Chrome that 99% of people use.&lt;/p&gt;

&lt;p&gt;So Tab Memory Manager uses calibrated estimates based on tab state, domain patterns, and known Chrome process overhead. These are clearly labeled "est." in the UI. If you're on Dev or Canary, you can switch on real per-tab memory in settings.&lt;/p&gt;

&lt;p&gt;The warning Chrome shows about "processes requires dev channel" is a Chrome-generated note about that optional API — the extension works completely normally without it. It's not a bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  The privacy angle
&lt;/h2&gt;

&lt;p&gt;No accounts. No servers. No analytics. No network requests at all (except to load the extension itself). Your tab data never leaves your browser.&lt;/p&gt;

&lt;p&gt;This was a hard constraint from the start — I didn't want to build a tool that phones home with your browsing habits in exchange for memory stats.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chrome's memory API situation is messier than I expected.&lt;/strong&gt; There's no clean "give me memory per tab" call on stable Chrome. The estimates approach took real calibration work.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hibernation UX is tricky.&lt;/strong&gt; Users expect the tab to stay exactly where it was. Making that feel seamless (instant reload on click, right scroll position, no flash) took more edge-case handling than the core feature.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"No frameworks" was the right call here.&lt;/strong&gt; The extension opens in under 100 ms. That matters when you're in the middle of a task and just want to quickly free some memory.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dark mode should be automatic, not optional.&lt;/strong&gt; Used CSS variables throughout so dark/light adapts to the user's system preference without any user-facing toggle.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;It's free on the Chrome Web Store. I'd genuinely love feedback — especially from people who've tried other tab suspenders and found them lacking.&lt;/p&gt;

&lt;p&gt;Link: &lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/tab-memory-manager/minmahgdnlfeehhooofdkjbhlodbobmd?authuser=0&amp;amp;amp%3Bhl=en-GB" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FniTqN2f9ViLTvJjFF-YaKWFrcRPeCUORjENY_WZ-7QR4NjJ6NkSE_IlB_C3D4kkjSKhK_VTpsGhkWFYh6jlCg-iXJYw%3Ds128-rj-sc0x00ffffff" height="128" class="m-0" width="128"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/tab-memory-manager/minmahgdnlfeehhooofdkjbhlodbobmd?authuser=0&amp;amp;amp%3Bhl=en-GB" rel="noopener noreferrer" class="c-link"&gt;
            Tab Memory Manager - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            See estimated memory per tab, hibernate or close idle tabs in one click, and group tabs by domain. Everything stays on your device.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png" width="48" height="48"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;What would make this more useful for your workflow?&lt;/p&gt;

</description>
      <category>extensions</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building Floatdeck - Chrome extension with 30+ tools and no backend</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Tue, 16 Jun 2026 18:47:12 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/i-built-a-local-only-chrome-extension-with-30-tools-and-no-backend-3933</link>
      <guid>https://dev.to/mohanvenkatakrishnan/i-built-a-local-only-chrome-extension-with-30-tools-and-no-backend-3933</guid>
      <description>&lt;p&gt;Weekend project to scratch an itch: I had five separate extensions — screenshot, dark mode, reading mode, QR, link cleaner — each eating a toolbar slot, a couple clearly monetizing my data. So I rolled them into one draggable floating button that fans open into a radial menu of ~30 actions.&lt;br&gt;
A few decisions worth noting:&lt;/p&gt;

&lt;p&gt;No backend, no analytics, zero network calls. Everything runs locally. That constraint makes the privacy story trivially honest and removes all the server/auth/policy overhead. If a feature needed a server, it didn't make the cut. Bundle's ~50KB.&lt;br&gt;
Vanilla JS, MV3, Shadow DOM. Since it injects into every page, I wanted it tiny — no framework. Shadow DOM to isolate the button from host-page CSS, which killed a whole class of style-collision bugs.&lt;br&gt;
The radial menu — not sure it was right. Looks great, feels nice for 5–6 items, but past that I doubt it beats a plain list for scanability (Fitts's law hates small icons on an arc). Kept it for the aesthetic. Curious if anyone's actually measured this.&lt;/p&gt;

&lt;p&gt;It's on the Chrome Web Store if you want to poke at it (disclosure: I'm the dev, free, no account, no telemetry). Mostly posting for the discussion though — how would you have handled the menu layout?&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/floatdeck-floating-button/fanagpncolgnoglmhamngmcnadkffmlo?hl=en-GB&amp;amp;amp%3Bauthuser=0" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FhtRHWT8R0azt7dj98lHIdH2Ps8iB_mAd0A5HLLMmqIn0nsjCf-5BHEWyvvUHxKPbiC6UaxmXGwKvbfANfpkvQ7SDRwc%3Ds128-rj-sc0x00ffffff" height="128" class="m-0" width="128"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/floatdeck-floating-button/fanagpncolgnoglmhamngmcnadkffmlo?hl=en-GB&amp;amp;amp%3Bauthuser=0" rel="noopener noreferrer" class="c-link"&gt;
            FloatDeck - Floating Button Menu &amp;amp; Quick Actions - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            A draggable floating button that opens a radial menu of quick actions — screenshots, reading &amp;amp; dark mode, QR, copy, and more.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png" width="48" height="48"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


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