<?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>Dollars and rupees without Stripe: what building Skill Exchange's checkout taught me (PayPal + UPI)</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Sun, 02 Aug 2026 19:45:22 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/dollars-and-rupees-without-stripe-what-building-skill-exchanges-checkout-taught-me-paypal-upi-3i8p</link>
      <guid>https://dev.to/mohanvenkatakrishnan/dollars-and-rupees-without-stripe-what-building-skill-exchanges-checkout-taught-me-paypal-upi-3i8p</guid>
      <description>&lt;p&gt;"Just add Stripe" is the default advice for a solo dev who needs to charge money. It works right up until you're an Indian merchant trying to sell to a buyer in San Francisco &lt;em&gt;and&lt;/em&gt; a buyer in Bangalore in the same afternoon — at which point you discover that a single provider can't actually do both, and the fix rearranges more of your code than you'd expect.&lt;/p&gt;

&lt;p&gt;I hit this building Skill Exchange (a marketplace for reusable AI skills — link at the end). The marketplace itself isn't the point here; it's the harness I used to figure out how to take money from two people on opposite sides of the world without a US entity and without Stripe. These are the things I'd tell another dev before they start.&lt;/p&gt;

&lt;h2&gt;
  
  
  PayPal in India is cross-border only — so it can't serve your Indian buyers at all
&lt;/h2&gt;

&lt;p&gt;The first assumption to kill: PayPal is not a domestic payment method in India. PayPal shut down India-to-India domestic payments in April 2021. What's left works &lt;em&gt;only&lt;/em&gt; cross-border — an Indian merchant can receive money from abroad, but a rupee-holding buyer in India cannot pay an India-based merchant through it.&lt;/p&gt;

&lt;p&gt;I learned this the way you'd expect: I wired up PayPal, tested it with my own (Indian) account against my own (Indian) merchant account, and got a cheerful, useless &lt;em&gt;"Things don't appear to be working at the moment."&lt;/em&gt; No error code, no docs entry — the transaction is simply classified as domestic and refused. It works flawlessly the moment the buyer is international, which is exactly what makes the failure so confusing when you're testing from your own country.&lt;/p&gt;

&lt;p&gt;Takeaway: if any of your buyers are in the same country as your PayPal merchant account, PayPal alone will strand them. You need a second rail.&lt;/p&gt;

&lt;h2&gt;
  
  
  You need two providers, and the split is by currency, not features
&lt;/h2&gt;

&lt;p&gt;So the architecture is two providers, chosen by the buyer's currency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;International buyers → PayPal&lt;/strong&gt;, charged in &lt;strong&gt;USD&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indian buyers → Razorpay&lt;/strong&gt;, charged in &lt;strong&gt;INR&lt;/strong&gt; via &lt;strong&gt;UPI&lt;/strong&gt; (one-tap, near-zero friction, far higher conversion than cards in India).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important design decision is that &lt;em&gt;the buyer&lt;/em&gt; chooses the rail, at checkout, by choosing a currency. I default the toggle by timezone and let them override it — no IP lookup, no external call:&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;defaultCurrency&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tz&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Kolkata&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;tz&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Calcutta&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;INR&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;USD&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A timezone heuristic is wrong for NRIs and travellers — which is exactly why it's only a &lt;em&gt;default&lt;/em&gt;, never a lock. The toggle is always there.&lt;/p&gt;

&lt;p&gt;Then the &lt;code&gt;buy&lt;/code&gt; endpoint routes on that one field:&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;buySkill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;skillId&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;skill&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;getSkill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skillId&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;wantsInr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&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="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INR&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;wantsInr&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;paise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usdCentsToInrPaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceCents&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;order&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;razorpay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;skillId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buyerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;        &lt;span class="c1"&gt;// notes bind it to this buyer&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;razorpay&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INR&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&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;paypal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;amountUsd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;skill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;skillId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buyerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;                     &lt;span class="c1"&gt;// custom_id binds it&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paypal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;skill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Don't show an exact FX conversion — round to local price points
&lt;/h2&gt;

&lt;p&gt;The naive move is to convert the USD price at some rate and show the result. Don't. A $12 skill at ₹86/\$ is ₹1,032, which looks like a bug and &lt;em&gt;feels&lt;/em&gt; expensive — Indian willingness-to-pay for digital goods is lower than a dollar conversion implies. Every serious seller into India uses regional price points instead.&lt;/p&gt;

&lt;p&gt;So the conversion isn't &lt;code&gt;usd * rate&lt;/code&gt; — it rounds to a familiar ₹x99:&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;INR_RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// one knob; change it to re-price the whole catalogue&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usdCentsToInrPaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usdCents&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="nx"&gt;usdCents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rupees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usdCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;INR_RATE&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;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rupees&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// → ₹x99&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// paise&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// $5→₹399  $6→₹499  $9→₹799  $12→₹999  $15→₹1,299&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;INR_RATE&lt;/code&gt; constant re-prices 200+ listings at once, and the rounding doubles as a light purchasing-power discount without any per-item work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the currency and the commission on the transaction — never recompute
&lt;/h2&gt;

&lt;p&gt;This is the one that bites later if you skip it. The moment you have more than one currency (and a platform fee), you cannot derive money after the fact. Two rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persist the currency on every purchase row.&lt;/strong&gt; A bare &lt;code&gt;amountCents&lt;/code&gt; is ambiguous the second some of them are paise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute the platform commission at purchase time and store it.&lt;/strong&gt; If your rate ever changes — mine went 10% → 5% — historical rows must keep the rate they were sold under. A commission you recompute on read is a commission you'll eventually get wrong.
&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;purchase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;skillId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buyerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chargedMinorUnits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// cents for USD, paise for INR&lt;/span&gt;
  &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                         &lt;span class="c1"&gt;// "USD" | "INR"  — never omit&lt;/span&gt;
  &lt;span class="na"&gt;commission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargedMinorUnits&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// frozen at sale time&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;providerPaymentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;purchasedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&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;And keep the seller-revenue aggregates &lt;em&gt;per currency&lt;/em&gt; — summing cents and paise into one integer is how you produce a beautiful, meaningless number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the charge server-side against the order you created — not what the client says
&lt;/h2&gt;

&lt;p&gt;Both rails let the browser lie to you, so the confirm step trusts neither the amount nor the "it succeeded" flag from the client. It re-derives the truth from the provider and checks it belongs to this exact buyer + item:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PayPal:&lt;/strong&gt; capture the order server-side, require &lt;code&gt;status === "COMPLETED"&lt;/code&gt;, and check the &lt;code&gt;custom_id&lt;/code&gt; you set at creation equals &lt;code&gt;skillId|buyerId&lt;/code&gt;. A captured order can't be replayed against a different purchase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Razorpay:&lt;/strong&gt; verify the checkout HMAC signature, then &lt;em&gt;fetch the order back&lt;/em&gt; and confirm its &lt;code&gt;notes&lt;/code&gt; match the skill and buyer you created it for — the order amount/currency you record come from that fetch, not from the request body.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Razorpay confirm&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="nf"&gt;verifyCheckoutSignature&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;forbid&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;order&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;razorpay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// source of truth&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;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skillId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;skillId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buyerId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;forbid&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;recordPurchase&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&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 is the same on both: the amount and currency you write to your ledger come from the provider's record of the order &lt;em&gt;you&lt;/em&gt; opened, cryptographically tied to the buyer — never from the client that's telling you it paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell the next person
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If a PayPal buyer and merchant share a country, PayPal strands them. Plan for a second rail from day one.&lt;/li&gt;
&lt;li&gt;Route by &lt;strong&gt;currency&lt;/strong&gt;, default it, and always let the buyer switch.&lt;/li&gt;
&lt;li&gt;Convert to &lt;strong&gt;local price points&lt;/strong&gt;, not exact FX.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze&lt;/strong&gt; currency + commission onto each transaction; aggregate revenue per currency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-derive&lt;/strong&gt; the charge server-side from the order you created, tied to the buyer. Trust the provider's record, not the client's word.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic — it's just the stuff nobody mentions when the answer is "add Stripe," and all of it is invisible until the first "Things don't appear to be working" from your own test account.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I built this while making &lt;a href="https://skillexchange.tapdot.org" rel="noopener noreferrer"&gt;Skill Exchange&lt;/a&gt; — a marketplace for reusable AI skills where every listing has to prove it works. Happy to answer payments questions in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>webdev</category>
      <category>saas</category>
      <category>ai</category>
    </item>
    <item>
      <title>Experiments with On-device AI — What building on Gemini Nano actually teaches you</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Fri, 17 Jul 2026 09:14:45 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/experiments-with-on-device-ai-what-building-on-gemini-nano-actually-teaches-you-5deo</link>
      <guid>https://dev.to/mohanvenkatakrishnan/experiments-with-on-device-ai-what-building-on-gemini-nano-actually-teaches-you-5deo</guid>
      <description>&lt;p&gt;Chrome ships a real LLM inside the browser now — Gemini Nano, exposed through a handful of built-in JS APIs (&lt;code&gt;LanguageModel&lt;/code&gt;, &lt;code&gt;Rewriter&lt;/code&gt;, &lt;code&gt;Proofreader&lt;/code&gt;, &lt;code&gt;Summarizer&lt;/code&gt;, &lt;code&gt;Writer&lt;/code&gt;). No API key, no network call, no per-token bill. It runs on-device, which sounds like a free lunch right up until you start building against it and discover that "on-device" changes almost every assumption you've built up shipping against OpenAI or Anthropic's APIs.&lt;/p&gt;

&lt;p&gt;I spent the last stretch building a small Chrome extension (&lt;a href="https://chromewebstore.google.com/detail/quill-by-tapdot-on-device/fkckfaljdbiiobmoedcmegnajgmafkeo" rel="noopener noreferrer"&gt;Quill&lt;/a&gt; — five writing tools, on-device AI only) as a testbed for exactly this. The extension itself isn't really the point here; it's the harness I used to find out what's actually different about building on a model that lives on the user's machine instead of yours. These are the things I'd tell another dev before they start.&lt;/p&gt;

&lt;h2&gt;
  
  
  There isn't one API — there's a task API and a fallback API, and you need both
&lt;/h2&gt;

&lt;p&gt;Chrome exposes two layers: dedicated task APIs (&lt;code&gt;Rewriter&lt;/code&gt;, &lt;code&gt;Proofreader&lt;/code&gt;, &lt;code&gt;Summarizer&lt;/code&gt;, &lt;code&gt;Writer&lt;/code&gt;), each shaped around one job with typed options, and the general-purpose &lt;code&gt;LanguageModel&lt;/code&gt; (the "Prompt API"), which you drive with a raw system prompt. They're gated by separate &lt;code&gt;chrome://flags&lt;/code&gt; entries and roll out to stable independently — which means on any given real machine, some subset of them is enabled and the rest aren't.&lt;/p&gt;

&lt;p&gt;Hard-coding against &lt;code&gt;Rewriter&lt;/code&gt; alone means the feature silently stops working on any profile where only the Prompt API is on, or vice versa. The fix is a fallback chain per capability: try the dedicated API, and if it's unavailable, reconstruct the same task as a system prompt for &lt;code&gt;LanguageModel&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doRewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sharedContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lengthPref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variationHint&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;a&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;avail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rewriter&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;a&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;Rewriter&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="nx"&gt;sharedContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;as-is&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lengthPref&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;variationHint&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&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="c1"&gt;// Rewriter isn't enabled on this machine — do the same job via the Prompt API.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sharedContext&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; Preserve the original meaning. Output only the rewritten text.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;promptOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variationHint&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;variationHint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This roughly doubles the code per feature, and it's the difference between "works on my machine" and "works." Treat "the task API is enabled" as a runtime fact you check, not a build-time assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Unavailable" is a state machine, not a boolean
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;availability()&lt;/code&gt; doesn't return true/false — it returns &lt;code&gt;unavailable&lt;/code&gt;, &lt;code&gt;downloadable&lt;/code&gt;, &lt;code&gt;downloading&lt;/code&gt;, or &lt;code&gt;available&lt;/code&gt;. The first time I hit &lt;code&gt;unavailable&lt;/code&gt; on a clean profile with the flags apparently on, I assumed it meant the API genuinely couldn't be reached from a content script's isolated world, and started building an offscreen-document relay to work around a permissions boundary that, it turned out, didn't exist. The actual cause: I'd toggled the flag but never restarted the browser, so Chrome was still running with the old flag state. &lt;code&gt;unavailable&lt;/code&gt; and "you forgot to restart" produce an identical string.&lt;/p&gt;

&lt;p&gt;Once you're actually in the &lt;code&gt;downloadable&lt;/code&gt; or &lt;code&gt;downloading&lt;/code&gt; states, &lt;code&gt;create()&lt;/code&gt; takes a &lt;code&gt;monitor&lt;/code&gt; callback that reports download progress — the &lt;em&gt;first&lt;/em&gt; call to a newly-enabled API triggers a one-time model pull that can take a while, and if you don't wire this up, your UI just looks hung:&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;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sharedContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;as-is&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;a&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloadprogress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;showProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Downloading model… &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;Rewriter&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="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two takeaways: don't build a workaround for "unavailable" until you've confirmed it isn't just an unflipped flag or a pending download, and always assume the first real call from a fresh install is going to be slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model is small enough that instructions aren't guarantees
&lt;/h2&gt;

&lt;p&gt;Task APIs and the Prompt API both run a genuinely small model compared to what you're used to calling over HTTP. Concretely, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Length and count instructions are approximate.&lt;/strong&gt; Ask for "exactly one sentence" or "under 200 characters" and you'll get close, not exact, often enough that you can't display raw output where a hard limit matters — measure it in code and treat the model's attempt as a first draft, not ground truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Give me three different versions" undersells how similar they'll be.&lt;/strong&gt; A single call asking for 3 variants tends to return 3 variants of the same idea with light word-swaps. Better results come from &lt;em&gt;three separate calls&lt;/em&gt;, each with an explicit instruction to lean a different direction (shorter, more formal, more casual) — treat variation as something you engineer via prompts, not something you request as a parameter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output language isn't implicit.&lt;/strong&gt; Both &lt;code&gt;LanguageModel&lt;/code&gt; and the task APIs accept &lt;code&gt;expectedInputs&lt;/code&gt; / &lt;code&gt;expectedOutputs&lt;/code&gt; with a &lt;code&gt;languages&lt;/code&gt; array. Skip it and you get a console warning and (per Chrome's own docs) less reliable output attestation — it's a one-line fix that's easy to miss because nothing breaks without it, it just gets quietly worse.
&lt;/li&gt;
&lt;/ul&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;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;initialPrompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;expectedInputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&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;en&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;expectedOutputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&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;en&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  You can't build a CI pipeline that assumes the model exists
&lt;/h2&gt;

&lt;p&gt;There's no Gemini Nano on a GitHub Actions runner, and probably not on your laptop today either unless you've already downloaded it. If your test suite needs a real model, you don't have a test suite — you have a manual QA checklist. The only way to get real coverage is to mock &lt;code&gt;Rewriter&lt;/code&gt; / &lt;code&gt;Summarizer&lt;/code&gt; / &lt;code&gt;LanguageModel&lt;/code&gt; etc. as jsdom globals and run your actual, unmodified feature code against the mock.&lt;/p&gt;

&lt;p&gt;That works, with one sharp edge: your mock has to vary its output the same way the real model does, or it'll hide bugs instead of catching them. I had a tone-variant generator that deduped near-identical outputs (a legit anti-repetition guard), tested against a mock that returned the same canned string regardless of which prompt it was called with. Every "generate 3 variants" test passed — with exactly one result, because the mock made all three collapse to the same dedupe key, and an assertion checking "got at least one result" didn't notice it got the wrong &lt;em&gt;number&lt;/em&gt;. The mock's laziness happened to produce output that looked like success. Once the mock was made to vary its response per input (the same way any real generative model does), the test correctly started failing until the dedupe logic was actually right.&lt;/p&gt;

&lt;p&gt;General rule: if a feature fans out into N distinct things, your mock has to be capable of returning N distinct things, or you're not testing the fan-out — you're testing that your code doesn't crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser boundary is real, even if your first guess about it is wrong
&lt;/h2&gt;

&lt;p&gt;A content script — the code that runs on the page you're actually operating on — cannot navigate the tab to &lt;code&gt;chrome://&lt;/code&gt; URLs; that's Chrome policy, not a bug to route around. If part of your UX involves sending someone to &lt;code&gt;chrome://flags&lt;/code&gt; to enable a capability, that navigation has to be requested from the extension's background service worker instead, via &lt;code&gt;chrome.tabs.create&lt;/code&gt; or similar, triggered by a message from the content 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="c1"&gt;// content script: can't navigate to chrome:// itself&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;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPEN_FLAGS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rewriter-api-for-gemini-nano&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// background worker: can&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="nx"&gt;onMessage&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;msg&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;msg&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;OPEN_FLAGS&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="s2"&gt;`chrome://flags/#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the boundary I &lt;em&gt;correctly&lt;/em&gt; suspected existed — the mistake earlier was assuming the availability check had a similar boundary when it didn't. Worth internalizing both halves: some restrictions on what a page-level script can do are real and permanent, and the fix is to hand the action to a more privileged context. Others are red herrings that look identical from the error message alone. The only way to tell them apart is to isolate the actual cause before you build the workaround.&lt;/p&gt;




&lt;p&gt;None of this is Quill-specific — it's what building anything on Chrome's on-device AI actually involves, independent of what the feature does. &lt;/p&gt;

&lt;p&gt;If you want to see where these came out the other end: &lt;a href="https://chromewebstore.google.com/detail/quill-by-tapdot-on-device/fkckfaljdbiiobmoedcmegnajgmafkeo" rel="noopener noreferrer"&gt;Quill&lt;/a&gt; is five writing tools (rewrite, proofread, summarize, list/table, freeform compose) running entirely on Gemini Nano, free on the Chrome Web Store.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building LaunchPad: one product brief, 42 launch channels, and the bugs that almost sank it</title>
      <dc:creator>Mohan</dc:creator>
      <pubDate>Sat, 11 Jul 2026 15:01:25 +0000</pubDate>
      <link>https://dev.to/mohanvenkatakrishnan/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost-sank-it-3ii7</link>
      <guid>https://dev.to/mohanvenkatakrishnan/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost-sank-it-3ii7</guid>
      <description>&lt;h2&gt;
  
  
  Launching a Product: A Translation Problem
&lt;/h2&gt;

&lt;p&gt;Launching a product is a translation problem. Hacker News punishes anything that smells like marketing. X gives you 280 characters. The Play Store gives you 30 for a title and 80 for the short description, and the Chrome Web Store will reject a keyword-stuffed name outright. Same product, fifteen dialects.&lt;/p&gt;

&lt;p&gt;I kept paying that tax on my own extension launches, so I built &lt;strong&gt;&lt;a href="https://launch.tapdot.org" rel="noopener noreferrer"&gt;LaunchPad&lt;/a&gt;&lt;/strong&gt;: you write one product brief, and it drafts a native post for 42 channels — each in that channel's voice, with its real character limits enforced at generation time. It launched on Product Hunt this weekend. This post is about the parts that were harder than they looked.&lt;/p&gt;

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

&lt;p&gt;React 18 + Vite on AWS Amplify, Lambda + DynamoDB + API Gateway behind Cognito, Terraform for everything. The interesting choice is the AI layer: there isn't a server-side one. Drafting runs either on Chrome's built-in Gemini Nano (the Prompt API — free, on-device, nothing leaves your machine) or against the user's own OpenAI/Claude key, called directly from the browser. My servers never see a prompt, a draft, or a key. That's a privacy feature and a business model at once: my marginal cost per free user is roughly zero, so the entire product can be free for your first launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug That Ate Every AI Draft
&lt;/h2&gt;

&lt;p&gt;The editor is TipTap (ProseMirror). During generation, I lock each section by toggling &lt;code&gt;setEditable(false)&lt;/code&gt;. Drafts started vanishing: the AI would finish, the success toast would fire, and the editor would sit there empty.&lt;/p&gt;

&lt;p&gt;The cause took a full evening: &lt;strong&gt;TipTap v3 emits &lt;code&gt;update&lt;/code&gt; events for programmatic changes too.&lt;/strong&gt; &lt;code&gt;setEditable()&lt;/code&gt; and &lt;code&gt;setContent()&lt;/code&gt; both fire the same &lt;code&gt;onUpdate&lt;/code&gt; as a keystroke. So the lock itself triggered &lt;code&gt;onChange('')&lt;/code&gt; — an empty "manual edit" that overwrote the freshly generated draft in the store, stamped &lt;strong&gt;manual&lt;/strong&gt; provenance on it, and autosaved the damage.&lt;/p&gt;

&lt;p&gt;The fix is a ref that marks self-inflicted mutations:&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;applyingExternal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nl"&gt;onUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;editor&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;applyingExternal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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="c1"&gt;// our own mutation, not the user's&lt;/span&gt;
  &lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;htmlToMd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHTML&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&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="nx"&gt;applyingExternal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEditable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;readOnly&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;applyingExternal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;readOnly&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general lesson: any rich-text editor's change event answers "did the document change?", not "did the &lt;strong&gt;user&lt;/strong&gt; change it?". If you sync editor state to a store, you need to answer the second question yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stale Responses That Eat Local Edits
&lt;/h2&gt;

&lt;p&gt;Twice — once for projects, once for preferences — I hit the same shape of bug: a GET fires on mount, the user makes a local change, the GET response lands &lt;strong&gt;after&lt;/strong&gt; the change and replaces state wholesale. The user's brand-new custom channel just… evaporates. It surfaced as a flaky e2e test that only failed in-suite, which I almost retry-looped away before realizing the flake &lt;strong&gt;was&lt;/strong&gt; the bug.&lt;/p&gt;

&lt;p&gt;The guard is a version counter, not a lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;loadPrefs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &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;ver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;_prefsVer&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;prefs&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPrefs&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;_prefsVer&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;ver&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="c1"&gt;// a local write won the race — discard&lt;/span&gt;
  &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;prefs&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prefs&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;updatePrefs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fields&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;_prefsVer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_prefsVer&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prefs&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;fields&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;If your local state is the source of truth (it should be), a server response is a &lt;strong&gt;suggestion&lt;/strong&gt; that expires the moment the user acts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Character Limits: Models Can't Count
&lt;/h2&gt;

&lt;p&gt;Every channel section carries its real limit — HN titles ≤80, Play Store short description ≤80, App Store keywords ≤100. Small on-device models blow past them constantly, and no prompt phrasing fixes it, because counting characters just isn't something a language model does reliably.&lt;/p&gt;

&lt;p&gt;What shipped: the limit goes into the prompt ("HARD LIMIT … counting characters, not words — drop hashtags first"), the output gets measured in actual code, and there are up to two corrective retries that feed the overlong draft back with its measured length. Keep the shortest attempt. If it's &lt;strong&gt;still&lt;/strong&gt; over, say so honestly — "Title is still over its limit, trim it by hand" — and paint the counter red. Users forgive a model that can't count; they don't forgive a tool that pretends it can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bulk Generation Is a Distributed System in One Tab
&lt;/h2&gt;

&lt;p&gt;"Generate all 42" sounds like a for-loop. The for-loop version fails four ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;One 429 killed the run.&lt;/strong&gt; A rate limit on channel 9 abandoned channels 10–42. Now every channel runs regardless, outcomes are collected, and a report says &lt;code&gt;28 drafted · 1 failed (rate limit) · 2 skipped&lt;/code&gt; — and a failed channel keeps its previous draft &lt;strong&gt;and&lt;/strong&gt; its provenance metadata untouched.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Nothing saved until the end.&lt;/strong&gt; Autosave was debounced; 42 channels of continuous store writes reset the debounce forever, so a reload at channel 40 lost everything. Persistence now flushes after &lt;strong&gt;every&lt;/strong&gt; channel.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;No way out.&lt;/strong&gt; Cancel now stops after the current section and keeps the finished work — "Cancel — keep what's drafted" — because the alternative reading of cancel (throw everything away) is never what anyone wants.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Toast spam.&lt;/strong&gt; Leave the editor mid-run and each channel's success toast followed you around the app. During bulk runs, individual toasts are silenced; the report speaks once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these showed up until a real user ran a real 42-channel generation with a real flaky key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payment Bug: Your Email Is Not Your Identity
&lt;/h2&gt;

&lt;p&gt;The webhook from my payment provider (Lemon Squeezy) identifies the buyer by email, so I resolved email → user and upgraded them. Tested it, worked, shipped it.&lt;/p&gt;

&lt;p&gt;Then my own test payment upgraded… nothing I could see. The reason: &lt;strong&gt;one email can be several accounts.&lt;/strong&gt; My address existed twice in Cognito — a Google-federated identity (my real login) and a native password user (the e2e test account). The &lt;code&gt;Limit: 1&lt;/code&gt; lookup upgraded whichever DynamoDB returned first, which was the wrong one. Any real user who'd ever signed in two ways would have paid and stayed on the free plan.&lt;/p&gt;

&lt;p&gt;The fix is embarrassingly simple — update &lt;strong&gt;every&lt;/strong&gt; account matching the payer's email — but the class of bug is worth internalizing: email is contact info, not a primary key.&lt;/p&gt;

&lt;p&gt;While I was in there: &lt;code&gt;subscription_cancelled&lt;/code&gt; means &lt;strong&gt;will not renew&lt;/strong&gt;, not &lt;strong&gt;expired&lt;/strong&gt;. Downgrading on cancel steals the month the customer already paid for. Keep the tier until the &lt;code&gt;expired&lt;/code&gt; event arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two CSS Lines That Cost Half a Day Each
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;overflow-x: hidden&lt;/code&gt; on &lt;code&gt;html, body&lt;/code&gt; — added innocently for mobile sideways-scroll protection — &lt;strong&gt;silently disables &lt;code&gt;position: sticky&lt;/code&gt;&lt;/strong&gt; everywhere. The spec-compliant fix is &lt;code&gt;overflow-x: clip&lt;/code&gt;, which clips without creating a scroll container. My sticky top bar was broken for two deploys before anyone noticed.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;backdrop-filter&lt;/code&gt; on that same top bar creates a &lt;strong&gt;containing block&lt;/strong&gt;: any &lt;code&gt;position: fixed&lt;/code&gt; child gets trapped inside the bar's box. My ⌘K command palette rendered as a black strip inside the header and swallowed outside clicks until I portaled it to &lt;code&gt;document.body&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Regression Suite Is the Actual Product
&lt;/h2&gt;

&lt;p&gt;The thing I'd defend hardest isn't a feature. It's the 21-test Playwright suite that runs &lt;strong&gt;against production&lt;/strong&gt; — on every deploy as a release gate, and nightly on cron. Every bug a user reported this month became a permanent sentinel: security headers served, deep links returning real 200s (Amplify's SPA fallback quietly serves HTTP 404 on every route unless you add an explicit rewrite — renders fine, poisons link unfurlers), zero horizontal overflow at 390px, the sticky bar surviving a scroll, and the full OpenAI/Claude pipeline exercised with the provider APIs intercepted at the network layer — asserting auth headers, model, and body shape with zero API spend.&lt;/p&gt;

&lt;p&gt;A UI regression suite against the live site feels like overkill until the first time it catches a stripped stylesheet before a user does. Mine paid for itself in week one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It's At
&lt;/h2&gt;

&lt;p&gt;LaunchPad is live at &lt;strong&gt;&lt;a href="https://launch.tapdot.org" rel="noopener noreferrer"&gt;launch.tapdot.org&lt;/a&gt;&lt;/strong&gt; — there's a &lt;strong&gt;&lt;a href="https://launch.tapdot.org/demo" rel="noopener noreferrer"&gt;sample launch you can explore without signing up&lt;/a&gt;&lt;/strong&gt; if you want to see what one brief turns into. Everything is free for your first launch, including the cloud engines with your own key.&lt;/p&gt;

&lt;p&gt;I'd genuinely like feedback on the channel prompts — each of the 42 has a hand-tuned "native voice" instruction, and the difference between a Show HN that survives and one that sinks lives in those words. If you launch somewhere I don't cover, tell me and it'll probably exist by next week: most of this product was built from user requests within hours of hearing them.&lt;/p&gt;

&lt;p&gt;Happy to answer anything about the Prompt API, the BYO-key architecture, or the regression setup in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
    <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>
