<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: tommy</title>
    <description>The latest articles on Forem by tommy (@tommy_worklab).</description>
    <link>https://forem.com/tommy_worklab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810626%2Fa84c293d-22f7-43b3-884e-6d9cd70f5d76.png</url>
      <title>Forem: tommy</title>
      <link>https://forem.com/tommy_worklab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/tommy_worklab"/>
    <language>en</language>
    <item>
      <title>I Built a Skitch Alternative 2 Months Ago. It Now Has Twice the Tools.</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sat, 04 Apr 2026 23:53:58 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/i-built-a-skitch-alternative-2-months-ago-it-now-has-twice-the-tools-beb</link>
      <guid>https://forem.com/tommy_worklab/i-built-a-skitch-alternative-2-months-ago-it-now-has-twice-the-tools-beb</guid>
      <description>&lt;p&gt;There's no pen tool.&lt;/p&gt;

&lt;p&gt;I found a bug, took a screenshot with my own tool, drew an arrow, placed some text. So far so good. But I wanted to &lt;strong&gt;circle this curved area freehand&lt;/strong&gt;. I looked for a pen tool. It wasn't there.&lt;/p&gt;

&lt;p&gt;I made this thing. And it doesn't have what I need.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://zenn.dev/ai_worklab/articles/b7cf09e400d8fc" rel="noopener noreferrer"&gt;Two months ago&lt;/a&gt;, I wrote about building a Skitch alternative in one day. That version had 5 tools: arrow, text, rectangle, mosaic, and numbered circles. "This is enough," I thought.&lt;/p&gt;

&lt;p&gt;It wasn't. Using it every day revealed what was missing.&lt;/p&gt;

&lt;p&gt;This is the story of growing from 5 tools to 9.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;PureMark Annotate&lt;/strong&gt; — Opens in your browser. No install, no login, nothing leaves your device.&lt;br&gt;
&lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;annotate.puremark.app&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Why I Needed It&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pen (freehand)&lt;/td&gt;
&lt;td&gt;Rectangles can't outline curved shapes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marker (highlight)&lt;/td&gt;
&lt;td&gt;Needed semi-transparent emphasis without hiding text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Straight line&lt;/td&gt;
&lt;td&gt;Less assertive than arrows for "from here to here"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ellipse&lt;/td&gt;
&lt;td&gt;Rectangles look awkward around non-rectangular targets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Beyond tools, I fixed friction points I discovered through daily use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Undo&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;50-step history stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stroke size&lt;/td&gt;
&lt;td&gt;Fixed&lt;/td&gt;
&lt;td&gt;S / M / L&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text size&lt;/td&gt;
&lt;td&gt;Fixed (24px)&lt;/td&gt;
&lt;td&gt;S(18) / M(24) / L(36)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export&lt;/td&gt;
&lt;td&gt;3x scale + PNG&lt;/td&gt;
&lt;td&gt;1x + JPEG 0.92&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What Google Search Console Taught Me
&lt;/h2&gt;

&lt;p&gt;I was checking GSC data when something caught my eye.&lt;/p&gt;

&lt;p&gt;Annotate's impressions spiked one week. +450%. The search queries: "screenshot annotation tool free", "add arrow to screenshot", "image markup online".&lt;/p&gt;

&lt;p&gt;These weren't developers.&lt;/p&gt;

&lt;p&gt;I'd assumed my tool was for developers because &lt;em&gt;I'm&lt;/em&gt; a developer. But GSC said otherwise. People who annotate screenshots aren't just engineers — they're PMs filing bug reports, IT staff writing manuals, directors giving design feedback.&lt;/p&gt;

&lt;p&gt;That's when I pivoted. I changed meta descriptions to Japanese (85% of users were from Japan, but descriptions were in English). I changed &lt;code&gt;applicationCategory&lt;/code&gt; from &lt;code&gt;DeveloperApplication&lt;/code&gt; to &lt;code&gt;MultimediaApplication&lt;/code&gt;. Added JSON-LD structured data. Basic stuff I'd completely overlooked.&lt;/p&gt;




&lt;h2&gt;
  
  
  iPhone Images Went from 5MB to 800KB
&lt;/h2&gt;

&lt;p&gt;One day I saved an annotated image on my iPhone. 5.2MB. Too heavy to paste into chat.&lt;/p&gt;

&lt;p&gt;The culprit was 3x export scaling. I'd been exporting at triple resolution for Retina sharpness, but 1x was perfectly fine in practice. Switching from PNG to JPEG (quality: 0.92) sealed the deal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: 3x PNG — 5.2MB&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: 1x JPEG — 0.8MB&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;85% reduction in file size. The quality difference? You'd have to put them side by side to notice.&lt;/p&gt;

&lt;p&gt;Users don't want "beautiful images." They want images they can send &lt;em&gt;right now&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Works great on mobile too. iPhone, Android — just a browser.&lt;br&gt;
&lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;annotate.puremark.app&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Android Camera OOM'd
&lt;/h2&gt;

&lt;p&gt;Testing on a Redmi 12 5G (4GB RAM), I took a photo and switched back to the browser. White screen. OOM — out of memory.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;capture="environment"&lt;/code&gt; triggers the system camera, which generates 12-50 megapixel images. The browser tries to decode at full size and crashes before reaching any resize logic.&lt;/p&gt;

&lt;p&gt;The fix: implement an in-app camera with &lt;code&gt;getUserMedia&lt;/code&gt; and cap resolution at 1920x1440.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&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;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;environment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&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;But that alone didn't work. My Cloudflare &lt;code&gt;_headers&lt;/code&gt; had &lt;code&gt;Permissions-Policy: camera=()&lt;/code&gt; — blocking camera API for all origins. The browser silently denied access without even showing a permission dialog. Changed to &lt;code&gt;camera=(self)&lt;/code&gt; and it worked.&lt;/p&gt;

&lt;p&gt;Security headers protect you when configured correctly. When they're not, they become invisible bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Undo Almost Killed It
&lt;/h2&gt;

&lt;p&gt;Adding drawing tools meant I needed Undo. You draw a pen stroke, realize it's wrong, and want to go back.&lt;/p&gt;

&lt;p&gt;I wrote about the implementation details in a &lt;a href="https://zenn.dev/ai_worklab/articles/canvas-api-drawing-tools-undo-redo" rel="noopener noreferrer"&gt;separate article&lt;/a&gt;. The short version: Canvas API has no concept of "remove an element" like the DOM. Every undo clears the canvas and redraws all annotations from scratch. 50-step history stack — undo removes the last item and redraws, redo adds it back.&lt;/p&gt;

&lt;p&gt;Sounds simple, but mosaic has a different draw order (it reads pixels from the original image), so rendering splits into 3 passes: Pass 1: Mosaic, Pass 2: Highlights, Pass 3: Everything else. Get the order wrong and mosaic overlaps your annotations.&lt;/p&gt;




&lt;h2&gt;
  
  
  2 Months in Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;February (launch)&lt;/th&gt;
&lt;th&gt;April (now)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MAU&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;328&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zenn articles&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome extension installs&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;52&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSC impressions (Annotate)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;peak &lt;strong&gt;80/week&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MAU is 328. The Gate 1 target is 500 — I'm at 65%. Still a ways to go, but the tool feels like something I &lt;em&gt;want&lt;/em&gt; to use now.&lt;/p&gt;

&lt;p&gt;Cross-tool navigation rate (users who visit other PureMark tools) exceeded 20%. One in five people use the full suite, not just Annotate. That was a good number to see.&lt;/p&gt;




&lt;h2&gt;
  
  
  Beyond "There's No Pen"
&lt;/h2&gt;

&lt;p&gt;Two months ago, I noticed there was no pen. Today, there is. And a marker, and ellipses, and Undo.&lt;/p&gt;

&lt;p&gt;But I'll notice something else missing soon. Because I'm still the primary user.&lt;/p&gt;

&lt;p&gt;The best part of building your own tool is that the distance from "this is missing" to "done" is zero. No tickets, no sprint planning. You notice it and you ship it. That's how 5 became 9. I don't know what 9 becomes next.&lt;/p&gt;




&lt;p&gt;A tool that started with "just draw an arrow on a screenshot" isn't just about arrows anymore.&lt;br&gt;
&lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;PureMark Annotate — Try it now in your browser&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zenn.dev/ai_worklab/articles/b7cf09e400d8fc" rel="noopener noreferrer"&gt;Previous article: I Built a Skitch Alternative in One Day&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zenn.dev/ai_worklab/articles/canvas-api-drawing-tools-undo-redo" rel="noopener noreferrer"&gt;Canvas API Drawing Tools &amp;amp; Undo Implementation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark — Developer Tool Suite&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>productivity</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Added 5 Drawing Tools to My Canvas App. Undo Almost Killed It.</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sat, 21 Mar 2026 22:34:17 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/i-added-5-drawing-tools-to-my-canvas-app-undo-almost-killed-it-1bnc</link>
      <guid>https://forem.com/tommy_worklab/i-added-5-drawing-tools-to-my-canvas-app-undo-almost-killed-it-1bnc</guid>
      <description>&lt;p&gt;The best part of building your own tools is that you can add whatever you want.&lt;/p&gt;

&lt;p&gt;I was filing a bug report and opened my own annotation tool to mark up a screenshot. I drew an arrow. But I wanted a &lt;strong&gt;freehand pen&lt;/strong&gt; to circle the area. No pen. I tried a rectangle — the thing I was highlighting wasn't rectangular. I wanted an ellipse. No ellipse either.&lt;/p&gt;

&lt;p&gt;I built this thing. And it was frustrating me.&lt;/p&gt;

&lt;p&gt;——So I added them. Today.&lt;/p&gt;




&lt;p&gt;This is the story of a major update to &lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;PureMark Annotate&lt;/a&gt; — a browser-based screenshot annotation tool (no install, no login). I started with 5 tools: arrow, text, rectangle, numbered circle, and mosaic. I added freehand pen, marker/highlight, straight line, ellipse, and Undo/Redo all at once.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;PureMark Annotate&lt;/strong&gt; — Works in your browser. No install, no account.&lt;br&gt;
👉 &lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;annotate.puremark.app&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Just adding tools to Canvas" sounded simple. There were 3 real gotchas. And they were fun.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pen Was Jagged
&lt;/h2&gt;

&lt;p&gt;The first implementation was straightforward. Collect coordinates in a &lt;code&gt;points&lt;/code&gt; array on every &lt;code&gt;mousemove&lt;/code&gt;, connect them with &lt;code&gt;lineTo&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slow strokes looked fine. Fast strokes looked like a bar chart.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mousemove&lt;/code&gt; doesn't fire continuously — it fires at intervals. Move fast and the gap between events gets large. You connect those distant points with a single straight line. That's your jagged pen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix: Quadratic Bézier curves through midpoints&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use the midpoint between adjacent points as the curve's endpoint, and the previous point as the control point. This interpolates a smooth curve between the raw coordinates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quadraticCurveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment I tested it, the line went smooth. I literally said "oh nice" out loud. A few lines of change, and fast strokes turned into proper curves. These little discoveries are what make indie dev fun.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Marker Got Darker When Overlapped
&lt;/h2&gt;

&lt;p&gt;The highlight/marker tool should've been simple: &lt;code&gt;globalAlpha = 0.35&lt;/code&gt;, then &lt;code&gt;fillRect&lt;/code&gt;. Done.&lt;/p&gt;

&lt;p&gt;But when I put an arrow on top of a highlighted area, the alpha values composed and the highlight got darker. Two overlapping highlights did the same. Something felt off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix: 3-pass rendering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every frame I clear the Canvas and redraw all annotations from scratch. I locked in this drawing order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pass 1: Mosaics&lt;/strong&gt; — must read from the original image first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass 2: Highlights&lt;/strong&gt; — rendered below everything else&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass 3: Everything else&lt;/strong&gt; — arrows, text, rectangles, pen strokes
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&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;Mosaic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;drawMosaic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageCanvas&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&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;Highlight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;drawHighlight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&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;Mosaic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&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;Highlight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;drawAnnotation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&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;Now highlights are always drawn exactly once per frame, so the opacity stays consistent. As a bonus, this "clear and redraw everything" approach turned out to be perfect for Undo/Redo too.&lt;/p&gt;




&lt;h2&gt;
  
  
  Undo Almost Killed the Tab
&lt;/h2&gt;

&lt;p&gt;This one was the most interesting.&lt;/p&gt;

&lt;p&gt;My first instinct: use &lt;code&gt;ctx.getImageData()&lt;/code&gt; to snapshot the whole Canvas on every stroke, and push it onto a history array. Simple, intuitive. I built it and ran it.&lt;/p&gt;

&lt;p&gt;After about 5 strokes, the Chrome tab silently died. No error. Just gone.&lt;/p&gt;

&lt;p&gt;The math made it obvious:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Typical iPhone photo&lt;/td&gt;
&lt;td&gt;4000 × 3000 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data per pixel&lt;/td&gt;
&lt;td&gt;4 bytes (RGBA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One snapshot&lt;/td&gt;
&lt;td&gt;4000 × 3000 × 4 = &lt;strong&gt;~46 MB&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 steps of history&lt;/td&gt;
&lt;td&gt;46 MB × 50 = &lt;strong&gt;~2.3 GB&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of course it crashed.&lt;/p&gt;




&lt;p&gt;I sat with it for a bit. Then it clicked: &lt;em&gt;I'm trying to save what the Canvas looks like. But what I actually need to save is what's drawn on it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The pixel data is just a rendering output. The real data is the annotations themselves — coordinates, color, type, stroke width. Pure JavaScript objects, a few dozen bytes each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix: history + future stacks of annotation arrays&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_HISTORY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// on finalize (stroke complete)&lt;/span&gt;
&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_HISTORY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentAnnotation&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// undo&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="na"&gt;future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;future&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// redo&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;future&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&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;50 steps of history. Memory cost: a few KB. Ctrl+Z goes back one step. Ctrl+Y goes forward. The obvious thing works obviously.&lt;/p&gt;

&lt;p&gt;When it clicked into place, I made a noise.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When &lt;code&gt;getImageData&lt;/code&gt; snapshots make sense:&lt;/strong&gt;&lt;br&gt;
If you need to undo pixel-level changes (like painting or blurring), you do need imageData. But for annotation tools where you're only adding and removing objects, storing the data array is all you need.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Before / After
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Drawing tools&lt;/td&gt;
&lt;td&gt;arrow, text, rect, counter, mosaic&lt;/td&gt;
&lt;td&gt;+ pen, marker, line, ellipse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;50 steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory for history&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;a few KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;PureMark Annotate&lt;/strong&gt; — Open it, paste a screenshot, annotate. Nothing to install.&lt;br&gt;
👉 &lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;annotate.puremark.app&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Now I can take a bug report screenshot, circle the problem with a pen, and hit Ctrl+Z if I mess up.&lt;/p&gt;

&lt;p&gt;The thing I wanted to exist, exists. I made it. That moment — where the tool you built does exactly what you needed — is probably my favorite part of building things for yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/quadraticCurveTo" rel="noopener noreferrer"&gt;CanvasRenderingContext2D.quadraticCurveTo() — MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData" rel="noopener noreferrer"&gt;CanvasRenderingContext2D.getImageData() — MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pqina.nl/blog/total-canvas-memory-use-exceeds-the-maximum-limit/" rel="noopener noreferrer"&gt;Canvas pixel limits on iOS Safari&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zustand-demo.pmnd.rs/" rel="noopener noreferrer"&gt;Zustand — lightweight state management&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The Day Skitch Stopped Working — Which Screenshot Annotation Tool Should You Use in 2026?</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Thu, 19 Mar 2026 11:04:09 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/the-day-skitch-stopped-working-which-screenshot-annotation-tool-should-you-use-in-2026-bm1</link>
      <guid>https://forem.com/tommy_worklab/the-day-skitch-stopped-working-which-screenshot-annotation-tool-should-you-use-in-2026-bm1</guid>
      <description>&lt;p&gt;I tried to annotate a bug report screenshot, like I do every morning.&lt;/p&gt;

&lt;p&gt;Skitch wouldn't launch.&lt;/p&gt;

&lt;p&gt;It was the day after I updated to macOS Tahoe. Nothing but a crash log. That familiar green icon — gone.&lt;/p&gt;

&lt;p&gt;It's 2026 now. Skitch's development effectively stopped in 2016. The Windows, iOS, and Android versions were officially discontinued back in January 2016. Only the Mac version kept working by some miracle. That miracle just ended.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://annotate.puremark.app/" rel="noopener noreferrer"&gt;PureMark Annotate&lt;/a&gt; is a free image annotation tool that works right in your browser. No installation, no account required. For those who loved Skitch's simplicity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A Decade of "Just Adding Arrows" — Gone
&lt;/h2&gt;

&lt;p&gt;What made Skitch great was that it didn't try to do too much.&lt;/p&gt;

&lt;p&gt;Take a screenshot. Add an arrow. Drop some text. Apply a mosaic blur. Done. Ten seconds from launch to finish. Daily bug reports, documentation images, diagrams for Slack — all Skitch.&lt;/p&gt;

&lt;p&gt;And now it won't open. Time to find a replacement. The first thing I saw was Snagit.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Couldn't Justify $39/Year for Snagit
&lt;/h2&gt;

&lt;p&gt;Snagit is the king of screenshot tools. Feature-wise, no complaints. But on February 12, 2025, TechSmith killed the perpetual license and switched to a &lt;strong&gt;$39/year subscription&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;$39 a year to add arrows.&lt;/p&gt;

&lt;p&gt;Reddit and Hacker News were full of users upset about what felt like a bait-and-switch — paying for something that used to be a one-time purchase. I get it. I closed that tab too.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Spent a Week Trying Every Alternative
&lt;/h2&gt;

&lt;p&gt;Here's what happened. I tried them all over the course of a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  CleanShot X ($29 one-time / macOS only)
&lt;/h3&gt;

&lt;p&gt;Honestly, the features are excellent. Scrolling capture, OCR, cloud sharing — it does everything.&lt;/p&gt;

&lt;p&gt;But it's macOS only. $29 one-time with one year of updates, then $19/year for continued updates. Great on my personal Mac, useless on my work Windows machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shottr (Free / macOS only)
&lt;/h3&gt;

&lt;p&gt;Light. Fast. Beautifully designed — Hacker News agreed.&lt;/p&gt;

&lt;p&gt;But again, macOS only. And the mosaic/blur feature wasn't as intuitive as I needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  ShareX (Free &amp;amp; Open Source / Windows only)
&lt;/h3&gt;

&lt;p&gt;On Windows, ShareX has a massive following. The feature set is enormous. But the moment I opened the settings panel, I understood why multiple reviews call it "overly complex."&lt;/p&gt;

&lt;p&gt;I just want to add arrows. And it doesn't run on Mac.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flameshot (Free &amp;amp; Open Source / Cross-platform)
&lt;/h3&gt;

&lt;p&gt;An open-source tool that came back to life with v13.0 in August 2024 after a 3-year hiatus. It runs on Linux, Mac, and Windows. Finally, cross-platform!&lt;/p&gt;

&lt;p&gt;But it's a desktop app that requires installation. My work PC doesn't have admin privileges. Dead end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lightshot (Free / Win &amp;amp; Mac)
&lt;/h3&gt;

&lt;p&gt;This is where I got nervous.&lt;/p&gt;

&lt;p&gt;Lightshot is popular, but it has a serious security flaw. Uploaded screenshots get a URL like &lt;code&gt;prnt.sc/&lt;/code&gt; followed by a 6-character alphanumeric code. &lt;strong&gt;You can guess URLs and view other people's screenshots.&lt;/strong&gt; Personal info, bank account screenshots, API keys on screen — all exposed. Missouri University of Science and Technology &lt;a href="https://econnection.mst.edu/2025/08/lightshot-screen-capture-app-blocked-due-to-cybersecurity-vulnerability/" rel="noopener noreferrer"&gt;blocked it on managed devices&lt;/a&gt; in August 2025.&lt;/p&gt;

&lt;p&gt;Bug report screenshots contain internal information. Not an option.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "No Install" Approach
&lt;/h2&gt;

&lt;p&gt;Back to square one.&lt;/p&gt;

&lt;p&gt;I listed my actual requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Arrows, text, and mosaic blur&lt;/strong&gt; (that's all I need)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Works on both Mac and Windows&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No installation required&lt;/strong&gt; (no admin rights on work PC)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free&lt;/strong&gt; (I'm not paying a subscription to add arrows)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images never leave my machine&lt;/strong&gt; (learned from the Lightshot incident)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Surprisingly few tools meet all five.&lt;/p&gt;

&lt;p&gt;I tried browser-based tools like Annotely and Annotation.com. Not bad. But the workflow of "type URL → upload file → annotate → download" was far from Skitch's "capture → annotate → paste" experience.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://annotate.puremark.app/" rel="noopener noreferrer"&gt;PureMark Annotate&lt;/a&gt; runs as a PWA. &lt;code&gt;Ctrl+V&lt;/code&gt; to paste an image, add annotations, &lt;code&gt;Ctrl+C&lt;/code&gt; to copy. Everything happens in the browser — images are never sent to any server.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Before / After
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before (Skitch era):&lt;/strong&gt;&lt;br&gt;
Launch Skitch → drag screenshot → add arrows &amp;amp; text → copy to clipboard → paste in Slack&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After (browser annotation):&lt;/strong&gt;&lt;br&gt;
Open Annotate in browser → &lt;code&gt;Ctrl+V&lt;/code&gt; to paste → add arrows, text, mosaic → &lt;code&gt;Ctrl+C&lt;/code&gt; to copy → paste in Slack&lt;/p&gt;

&lt;p&gt;Nearly identical workflow. The difference: no installation. No OS restriction. And your images stay local.&lt;/p&gt;

&lt;p&gt;Install it as a PWA, and it works just like a desktop app — even offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;OS&lt;/th&gt;
&lt;th&gt;Install&lt;/th&gt;
&lt;th&gt;Privacy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skitch&lt;/td&gt;
&lt;td&gt;Free (discontinued)&lt;/td&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snagit&lt;/td&gt;
&lt;td&gt;$39/year&lt;/td&gt;
&lt;td&gt;Win/Mac&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CleanShot X&lt;/td&gt;
&lt;td&gt;$29 (updates $19/yr)&lt;/td&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shottr&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ShareX&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flameshot&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lightshot&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Win/Mac&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;⚠️ URL guessing risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PureMark Annotate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Free&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;All (browser)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Local only&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;What I loved about Skitch was that "just adding arrows" didn't come with baggage.&lt;/p&gt;

&lt;p&gt;I couldn't find that simplicity in any desktop app. OS restrictions, admin permissions, subscriptions, privacy risks — every option required some compromise.&lt;/p&gt;

&lt;p&gt;Opening a browser tab turned out to be the closest thing to Skitch.&lt;/p&gt;

&lt;p&gt;Start annotating in 0 seconds → &lt;a href="https://annotate.puremark.app/" rel="noopener noreferrer"&gt;PureMark Annotate&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.techsmith.com/snagit/" rel="noopener noreferrer"&gt;Snagit 2025 Subscription Transition&lt;/a&gt; — TechSmith official&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://econnection.mst.edu/2025/08/lightshot-screen-capture-app-blocked-due-to-cybersecurity-vulnerability/" rel="noopener noreferrer"&gt;Lightshot blocked at Missouri S&amp;amp;T&lt;/a&gt; — University official news&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/flameshot-org/flameshot/releases/tag/v13.0.0" rel="noopener noreferrer"&gt;Flameshot v13.0&lt;/a&gt; — August 2024 release&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cleanshot.com/" rel="noopener noreferrer"&gt;CleanShot X&lt;/a&gt; — macOS screenshot tool&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>screenshot</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>tools</category>
    </item>
    <item>
      <title>How I Grew My Developer Tool Suite to 175 MAU in 3 Weeks — Zero Ad Spend, Articles Only</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Wed, 18 Mar 2026 03:12:41 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/how-i-grew-my-developer-tool-suite-to-175-mau-in-3-weeks-zero-ad-spend-articles-only-4o27</link>
      <guid>https://forem.com/tommy_worklab/how-i-grew-my-developer-tool-suite-to-175-mau-in-3-weeks-zero-ad-spend-articles-only-4o27</guid>
      <description>&lt;h2&gt;
  
  
  "Nobody's Coming" Is the Worst Part
&lt;/h2&gt;

&lt;p&gt;If you've ever shipped a side project, you know this feeling.&lt;/p&gt;

&lt;p&gt;Launch day. Open Google Analytics. Real-time users: &lt;strong&gt;0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Next day, still 0. Day 3, finally 1 — it was me.&lt;/p&gt;

&lt;p&gt;When I launched my developer tool suite, the first week's engaged MAU was &lt;strong&gt;5&lt;/strong&gt;. I didn't investigate how many were me.&lt;/p&gt;

&lt;p&gt;Three weeks later, MAU hit &lt;strong&gt;175&lt;/strong&gt;. I spent zero on ads.&lt;/p&gt;

&lt;p&gt;This is the full record — what worked, what flopped, and all the real numbers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The tool suite discussed in this article is &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; — 7 developer tools (JSON, Base64, Diff, JWT, Timestamp, and more) that work with zero clicks. Just copy something to your clipboard and open the page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I Built: 7 Tools, One Umbrella
&lt;/h2&gt;

&lt;p&gt;Small tools developers use daily — JSON formatting, Base64 decode, URL encode, diff comparison, JWT decode, timestamp conversion — bundled into &lt;strong&gt;one branded suite&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Annotate&lt;/td&gt;
&lt;td&gt;Add arrows &amp;amp; text to screenshots (PWA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON Formatter&lt;/td&gt;
&lt;td&gt;Auto-formats on paste&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base64 Encoder/Decoder&lt;/td&gt;
&lt;td&gt;One-action encode/decode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL Encoder/Decoder&lt;/td&gt;
&lt;td&gt;Percent-encoding conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diff Checker&lt;/td&gt;
&lt;td&gt;Text comparison with custom Myers diff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timestamp Converter&lt;/td&gt;
&lt;td&gt;Unix timestamp ↔ human-readable dates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT Decoder&lt;/td&gt;
&lt;td&gt;Safely inspect tokens locally&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every tool shares one design principle: &lt;strong&gt;zero click&lt;/strong&gt;. Copy something, open the page, and the result is already there. No Ctrl+V needed.&lt;/p&gt;

&lt;p&gt;Plus a Chrome extension (&lt;a href="https://chromewebstore.google.com/detail/puremark-detect/bgkjbihegmflnficilnmbanoadnappjf" rel="noopener noreferrer"&gt;PureMark Detect&lt;/a&gt;) that auto-detects clipboard format and opens the right tool in one click.&lt;/p&gt;

&lt;h2&gt;
  
  
  All the Numbers
&lt;/h2&gt;

&lt;p&gt;No point hiding them, so here's everything:&lt;/p&gt;

&lt;h3&gt;
  
  
  MAU Growth
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;W10 (2/28–3/2):    5  ← Measurement started. Mostly me.
W11 (3/3–3/9):    80  ← Published 3 Zenn articles at once
W12 (3/10–3/14): 135  ← SEO accumulation kicking in
W13 (3/14–3/18): 175  ← Zero articles published. Organic only.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  By Channel
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;W13 Numbers&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Zenn (JP dev blog)&lt;/td&gt;
&lt;td&gt;687 total PV / 13 likes&lt;/td&gt;
&lt;td&gt;12 articles. +4 likes in a no-publish week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev.to&lt;/td&gt;
&lt;td&gt;336 total PV&lt;/td&gt;
&lt;td&gt;11 English articles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Search&lt;/td&gt;
&lt;td&gt;22 impressions / 0 clicks&lt;/td&gt;
&lt;td&gt;Still impression-only stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome Extension&lt;/td&gt;
&lt;td&gt;23 installs&lt;/td&gt;
&lt;td&gt;1 active user (painful)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X (Twitter)&lt;/td&gt;
&lt;td&gt;1,242 impressions&lt;/td&gt;
&lt;td&gt;Basically crickets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Ad spend: $0.&lt;/strong&gt; Running costs: Cloudflare free tier + domain registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Worked #1: Designing Articles as SEO Machines
&lt;/h2&gt;

&lt;p&gt;90% of growth came from articles. But not random ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Article = One Problem, Not One Tool
&lt;/h3&gt;

&lt;p&gt;I never wrote "I built a JSON Formatter." Instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Do You Know Where Your JWT Goes When You Paste It Into a Developer Tool?"&lt;/strong&gt; — Lead with privacy concerns, introduce the tool as the solution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"130 Lines of Myers Diff From Scratch — And the Off-by-One Bug That Took 2 Days"&lt;/strong&gt; — Lead with a real debugging story&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"How I Turned My Development Logs Into Self-Accumulating Assets with Claude Code × Obsidian"&lt;/strong&gt; — Lead with a workflow pain point&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every article &lt;strong&gt;starts with a problem the reader recognizes&lt;/strong&gt;. The tool appears naturally as part of the solution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Stories Get Likes. Tutorials Don't.
&lt;/h3&gt;

&lt;p&gt;After 12 articles, the pattern is unmistakable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Articles That Got Likes&lt;/th&gt;
&lt;th&gt;Common Thread&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skitch Alternative PWA (3 likes)&lt;/td&gt;
&lt;td&gt;Flutter detour → PWA pivot. 3 hours on arrow rendering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code × Obsidian (5 likes)&lt;/td&gt;
&lt;td&gt;2 AM frustration → bind mount solution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Myers Diff 130 lines (1 like)&lt;/td&gt;
&lt;td&gt;Off-by-one bug that cost 2 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Every article without a failure story got zero likes.&lt;/strong&gt; n=12, no exceptions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Try the zero-click experience yourself: visit &lt;a href="https://json.puremark.app" rel="noopener noreferrer"&gt;JSON Formatter&lt;/a&gt; with some JSON in your clipboard — the result appears instantly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Worked #2: Making Tools "Refer" Each Other
&lt;/h2&gt;

&lt;p&gt;This is why I built a &lt;em&gt;suite&lt;/em&gt; instead of separate sites.&lt;/p&gt;

&lt;p&gt;When you format JSON and there's a JWT token in your clipboard, the tool shows a button: &lt;strong&gt;"Looks like a JWT. Decode it?"&lt;/strong&gt; Click it, and you're on the JWT Decoder with the token already parsed.&lt;/p&gt;

&lt;p&gt;W13's &lt;strong&gt;cross-tool transition rate was 5.75%&lt;/strong&gt; — 6 out of every 100 users moved from one tool to another.&lt;/p&gt;

&lt;p&gt;This has compound effects: longer sessions improve Google's quality signals, and users discover tools they didn't know existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Flopped — Spectacularly
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Show HN → Zero
&lt;/h3&gt;

&lt;p&gt;Posted to Hacker News. Result: &lt;strong&gt;0 upvotes, 0 comments.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd analyzed it beforehand — developer tools are a saturated category on HN, and without genuine technical novelty (novel algorithm, unique architecture), it won't land. Knowing that in advance kept the emotional damage manageable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson: Pre-calibrate your expectations.&lt;/strong&gt; "Light toss" mentality saved me from spiraling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Share URL Feature → 4 Weeks at 0% Usage
&lt;/h3&gt;

&lt;p&gt;Every tool has "recipe URLs" — encode input data in the URL hash so sharing a link reproduces the exact state. Technically elegant implementation.&lt;/p&gt;

&lt;p&gt;Times used: &lt;strong&gt;Zero. Four consecutive weeks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Button placement? No demand? I changed the UI in W13 — next week will tell.&lt;/p&gt;

&lt;h3&gt;
  
  
  X (Twitter) → Invisible
&lt;/h3&gt;

&lt;p&gt;Tweeted. Made demo videos. Result: 1,242 impressions total, 3 likes.&lt;/p&gt;

&lt;p&gt;Plenty of "use Twitter for indie marketing" advice out there. Reality: &lt;strong&gt;if your account has no followers, nothing reaches anyone.&lt;/strong&gt; Not even good content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Decisions That Mattered
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "Everything Runs Locally" Pays Off Slowly
&lt;/h3&gt;

&lt;p&gt;Every PureMark tool processes data in the browser. No server calls. Clipboard reading, JSON formatting, JWT decoding — all client-side.&lt;/p&gt;

&lt;p&gt;Initially just a differentiator. But when I wrote the privacy-focused article, it got &lt;strong&gt;2 likes in 4 days&lt;/strong&gt; — fast for my scale. "Do you know where your JWT goes?" resonated. There's a real audience that cares about this.&lt;/p&gt;

&lt;h3&gt;
  
  
  KPI Dashboard Before Tools
&lt;/h3&gt;

&lt;p&gt;I built the measurement infrastructure first. Cloudflare Workers + KV, pulling GA4/GSC/Zenn/Dev.to/X data every 6 hours into one dashboard.&lt;/p&gt;

&lt;p&gt;Best decision I made. Without numbers, you can't tell what's working. The difference between "article week" and "no-article week" is visible in data, which makes the next action obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  3-Week Takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Worked
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zenn article SEO accumulation&lt;/strong&gt; — MAU grew +30% in W13 with zero new articles. Content is self-sustaining.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Story-driven articles&lt;/strong&gt; — Only articles with failure stories earned likes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-tool navigation UI&lt;/strong&gt; — 5.75% transition rate validates the suite strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev.to English versions&lt;/strong&gt; — GSC impressions from English queries growing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Didn't Work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Show HN&lt;/strong&gt; — 0 upvotes. Saturated category needs technical novelty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X (Twitter)&lt;/strong&gt; — Doesn't work without an existing audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share URLs&lt;/strong&gt; — 4 weeks at 0% usage. Feature ≠ adoption.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Still Unknown
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When will GSC clicks start?&lt;/strong&gt; — 22 impressions but 0 clicks. Need higher search rankings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome extension active rate&lt;/strong&gt; — 23 installs, 1 active. Need a reason to keep using it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is this growth rate sustainable?&lt;/strong&gt; — Gate 1 target is MAU 500 by mid-April. Need +11%/week.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before / After
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before (3 weeks ago):&lt;/strong&gt;&lt;br&gt;
Built 7 tools. Was proud of them. MAU: 5. Show HN: 0. "Build it and they will come" was a lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After (now):&lt;/strong&gt;&lt;br&gt;
MAU 175. ~30% weekly growth. Users increasing even in weeks with no new content. What changed wasn't the product — it was &lt;strong&gt;spending equal time on "how to reach people" as on writing code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In indie development, writing code is half the job. The other half is getting discovered, and there are reproducible methods for that.&lt;/p&gt;




&lt;p&gt;All tools run in your browser. No sign-up, no data sent to servers. Try them → &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;puremark.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>productivity</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Do You Know Where Your JWT Goes When You Paste It Into an Online Tool?</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sat, 14 Mar 2026 00:19:53 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/do-you-know-where-your-jwt-goes-when-you-paste-it-into-an-online-tool-o97</link>
      <guid>https://forem.com/tommy_worklab/do-you-know-where-your-jwt-goes-when-you-paste-it-into-an-online-tool-o97</guid>
      <description>&lt;h2&gt;
  
  
  The Moment I Froze
&lt;/h2&gt;

&lt;p&gt;Pasting a JWT token into an online decoder. Throwing API response JSON into a formatter. Diffing code with an online tool.&lt;/p&gt;

&lt;p&gt;If you're a developer, you probably do this every day.&lt;/p&gt;

&lt;p&gt;I did too. One day, while decoding a JWT as usual, it hit me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"This token contains the user's email and role info… where did it just get sent?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I opened DevTools' Network tab. A POST request had fired. My input data was being sent to a server.&lt;/p&gt;

&lt;p&gt;No malicious intent, obviously. Server-side processing is just how it's designed. But when you think about it, it's unsettling.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This experience led me to build &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; — a developer tool suite that runs entirely in the browser. JSON / Base64 / URL / Diff / Timestamp / JWT — none of the six tools ever send your data externally.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  It's Already Happening — Data Leaks Via Online Tools
&lt;/h2&gt;

&lt;p&gt;"Am I overthinking this?" you might wonder. But incidents have already occurred.&lt;/p&gt;

&lt;h3&gt;
  
  
  80,000 AWS Keys Leaked (November 2025)
&lt;/h3&gt;

&lt;p&gt;Security firm watchTowr Labs discovered that &lt;strong&gt;over 80,000 AWS keys, passwords, and API secrets&lt;/strong&gt; had leaked from jsonformatter.org and codebeautify.org. Five years of stored data, over 5GB. Government agencies, banks, telecoms, and aerospace organizations were affected.&lt;/p&gt;

&lt;p&gt;Developers had pasted data into these sites for JSON formatting and validation, and that data was stored on servers. It was provided as a "save" feature, but most users had no idea their credentials were being retained.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I also covered this in a previous article: &lt;a href="https://zenn.dev/naoki506/articles/json-formatter-zero-click-clipboard-api" rel="noopener noreferrer"&gt;JSON Formatter Dev Log: Zero-Click Formatting and Clipboard API Pitfalls&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Samsung ChatGPT Incident (March 2023)
&lt;/h3&gt;

&lt;p&gt;Samsung engineers input confidential source code and meeting transcripts into ChatGPT, leaking information externally. Three leaks occurred within just one month, forcing Samsung to ban internal ChatGPT usage.&lt;/p&gt;

&lt;p&gt;This isn't about online tools specifically, but the &lt;strong&gt;risk of sending data to external services&lt;/strong&gt; has the same structure.&lt;/p&gt;

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

&lt;p&gt;I'm not here to criticize. Many online developer tools use server-side processing because it's easier to implement and handles complex operations well. It's a rational choice.&lt;/p&gt;

&lt;p&gt;The problem is that users aren't aware of it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT tokens contain claims like &lt;code&gt;sub&lt;/code&gt; (user ID), &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;role&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;API response JSON may contain customer data or internal IDs&lt;/li&gt;
&lt;li&gt;Code pasted into diff tools might be unreleased feature implementations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if a privacy policy says "we don't store your data," &lt;strong&gt;risk isn't zero the moment data is transmitted&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything Could Run Client-Side
&lt;/h2&gt;

&lt;p&gt;With PureMark, all six tools run entirely in the browser. Not a single line of server-side code.&lt;/p&gt;

&lt;p&gt;Honestly, I first thought "is that even possible?" But when I investigated, browsers' standard APIs can do surprisingly much.&lt;/p&gt;

&lt;h3&gt;
  
  
  JSON Formatting: Done in 2 Lines
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&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="nx"&gt;raw&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;formatted&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="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validation, formatting, minification — all done with &lt;code&gt;JSON.parse&lt;/code&gt; + &lt;code&gt;JSON.stringify&lt;/code&gt;. Absolutely no need to send to a server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Base64: Browser Native API
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// encode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// decode&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unicode support required combining with &lt;code&gt;encodeURIComponent&lt;/code&gt;, but it still completes entirely in the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT: Just Base64 Decode
&lt;/h3&gt;

&lt;p&gt;JWT structure is three parts separated by dots: &lt;code&gt;header.payload.signature&lt;/code&gt;. Header and payload are Base64URL-encoded JSON, so decoding reveals the claims.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&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="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secret key signature verification isn't done on the frontend anyway (you can't expose the secret key in the browser). Decoding alone requires no server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Try using PureMark, then open DevTools' Network tab. Besides GA4 beacons, you'll see zero external requests.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://json.puremark.app" rel="noopener noreferrer"&gt;json.puremark.app&lt;/a&gt; / &lt;a href="https://jwt.puremark.app" rel="noopener noreferrer"&gt;jwt.puremark.app&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Diff: 130-Line Custom Implementation
&lt;/h3&gt;

&lt;p&gt;This was the one exception where "2 lines with standard APIs" didn't cut it.&lt;/p&gt;

&lt;p&gt;Displaying text differences requires the Myers diff algorithm. An external library would make it instant, but I wanted &lt;strong&gt;zero external dependencies&lt;/strong&gt;. The result: about 130 lines of TypeScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Went Wrong Without a Server
&lt;/h2&gt;

&lt;p&gt;Client-side completion sounds ideal, but it comes with significant constraints. Here are the failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Myers Diff Off-by-One Bug
&lt;/h3&gt;

&lt;p&gt;My custom Myers diff implementation produced a bug where highlight positions shifted on specific patterns.&lt;/p&gt;

&lt;p&gt;The cause: an index calculation in backtrack processing used &lt;code&gt;k&lt;/code&gt; where it should have been &lt;code&gt;k - 1&lt;/code&gt;. A classic off-by-one error.&lt;/p&gt;

&lt;p&gt;Tests passed. But it broke on edge cases.&lt;/p&gt;

&lt;p&gt;I noticed the day after deployment and broke into a cold sweat. External libraries offer battle-tested code, but custom implementations carry this kind of risk. I still chose zero external dependencies to minimize bundle size and eliminate license risks.&lt;/p&gt;

&lt;h3&gt;
  
  
  2000-Character URL Limit
&lt;/h3&gt;

&lt;p&gt;No server means no database to store shortened URLs.&lt;/p&gt;

&lt;p&gt;My solution: a "recipe URL" approach. Base64-encode tool input data and embed it in the URL hash.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://json.puremark.app/#d=eyJhYmMiOiJkZWYifQ&amp;amp;m=format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This restores tool state from the URL alone. No server needed.&lt;/p&gt;

&lt;p&gt;...or so I thought, until slightly longer JSON exceeded 2000 characters. The browser URL length limit hits, and the share button grays out.&lt;/p&gt;

&lt;p&gt;For now, I've accepted this limitation. Long data sharing falls back to a file download feature. A server could solve it by storing URL shortener keys in a DB, but that violates the "don't send data" principle. It was a trade-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  SEO Disadvantage
&lt;/h3&gt;

&lt;p&gt;SPAs can't do server-side rendering (SSR), which hurts search engine indexing.&lt;/p&gt;

&lt;p&gt;I serve static HTML via Cloudflare Pages with pre-generated OGP images and meta tags. Still, compared to SSR frameworks like Next.js, SEO remains a handicap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Still Choose Client-Side
&lt;/h2&gt;

&lt;p&gt;Constraints exist. But what you gain outweighs them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;With Server&lt;/th&gt;
&lt;th&gt;PureMark (No Server)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where data goes&lt;/td&gt;
&lt;td&gt;Sent to server&lt;/td&gt;
&lt;td&gt;Never leaves browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly server cost&lt;/td&gt;
&lt;td&gt;$20+ minimum&lt;/td&gt;
&lt;td&gt;$0 (Cloudflare Pages free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline support&lt;/td&gt;
&lt;td&gt;Not possible&lt;/td&gt;
&lt;td&gt;Works as PWA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response speed&lt;/td&gt;
&lt;td&gt;Network round-trip&lt;/td&gt;
&lt;td&gt;Instant processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User verifiability&lt;/td&gt;
&lt;td&gt;Impossible if source is closed&lt;/td&gt;
&lt;td&gt;Checkable via DevTools Network tab&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;User trust &amp;gt; SEO score.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was my conclusion after building 6 tools in 2 weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / After
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Is this safe...?" every time pasting JWT into an online tool&lt;/td&gt;
&lt;td&gt;Can verify with your own eyes that no requests are firing in Network tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hesitating to paste customer data into JSON formatters&lt;/td&gt;
&lt;td&gt;No hesitation when data never leaves the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Afraid to paste unreleased code into diff tools&lt;/td&gt;
&lt;td&gt;Don't even think about it with local processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opening different sites for each tool&lt;/td&gt;
&lt;td&gt;One-click switching via PureMark's navbar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Data pasted into developer tools is more sensitive than you think. Once I realized that, I started paying attention to what's happening behind the tools I use.&lt;/p&gt;

&lt;p&gt;PureMark was born from that realization — a tool I myself can use with peace of mind.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;puremark.app&lt;/a&gt; — JSON / Base64 / URL / Diff / Timestamp / JWT, all browser-complete.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zenn.dev/naoki506/articles/json-formatter-zero-click-clipboard-api" rel="noopener noreferrer"&gt;JSON Formatter Dev Log: Zero-Click Formatting and Clipboard API Pitfalls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zenn.dev/naoki506/articles/jwt-decoder-base64url-padding" rel="noopener noreferrer"&gt;I Built a JWT Decoder and atob() Didn't Work — Half a Day Lost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zenn.dev/naoki506/articles/myers-diff-130lines-off-by-one" rel="noopener noreferrer"&gt;Implementing Text Diff Without Libraries — Myers Diff 130-Line Pitfall&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://thehackernews.com/2025/11/years-of-jsonformatter-and-codebeautify.html" rel="noopener noreferrer"&gt;Years of JSONFormatter and CodeBeautify Leaks Expose Thousands of Passwords and API Keys - The Hacker News&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.bloomberg.com/news/articles/2023-05-02/samsung-bans-chatgpt-and-other-generative-ai-use-by-staff-after-leak" rel="noopener noreferrer"&gt;Samsung Bans Generative AI Use by Staff After ChatGPT Data Leak - Bloomberg&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API" rel="noopener noreferrer"&gt;Clipboard API - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="http://www.xmailserver.org/diff2.pdf" rel="noopener noreferrer"&gt;Myers diff algorithm paper (1986)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pages.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare Pages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>privacy</category>
      <category>typescript</category>
    </item>
    <item>
      <title>What If the Page Could Detect Data Formats Without Copying? — Adding Page Scan + JWT Detection to a Chrome Extension</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Fri, 13 Mar 2026 22:26:44 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/what-if-the-page-could-detect-data-formats-without-copying-adding-page-scan-jwt-detection-to-a-21hk</link>
      <guid>https://forem.com/tommy_worklab/what-if-the-page-could-detect-data-formats-without-copying-adding-page-scan-jwt-detection-to-a-21hk</guid>
      <description>&lt;h2&gt;
  
  
  The Limits of Clipboard Detection
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/tommy_worklab/what-if-your-clipboard-knew-what-it-contained-building-a-chrome-extension-for-auto-detection-fnk"&gt;my previous article&lt;/a&gt;, I built a Chrome extension that auto-detects clipboard data formats. Copy → click icon → detect → open in tool. Handy.&lt;/p&gt;

&lt;p&gt;After using it for a while, something bugged me.&lt;/p&gt;

&lt;p&gt;I was reading a Qiita article. A JSON API response was displayed in a &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; block. Deeply nested, hard to read. I wanted to format it.&lt;/p&gt;

&lt;p&gt;I selected the JSON with my mouse. Ctrl+C. Clicked the extension icon. Popup opened. "Format JSON" appeared. Clicked. Tool opened and formatted it.&lt;/p&gt;

&lt;p&gt;Wait — &lt;strong&gt;the data is already on screen. Why do I need to copy it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There was another problem. Copying JSON from Japanese tech blogs introduced smart quotes (&lt;code&gt;""&lt;/code&gt; → &lt;code&gt;""&lt;/code&gt;) and trailing commas. The CMS silently converts quotes to "pretty" curly quotes. On screen it looks like &lt;code&gt;"name"&lt;/code&gt;, but the clipboard contains &lt;code&gt;\u201Cname\u201D&lt;/code&gt;. &lt;code&gt;JSON.parse()&lt;/code&gt; fails instantly.&lt;/p&gt;

&lt;p&gt;"If I scan the code blocks directly from the page, no copying needed, and no CMS interference."&lt;/p&gt;

&lt;p&gt;Built it.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;Chrome extension &lt;strong&gt;PureMark Detect&lt;/strong&gt; is available on the Chrome Web Store.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://chromewebstore.google.com/detail/puremark-detect/llmjencpmkmfflggbgndphopclcbkdhi" rel="noopener noreferrer"&gt;PureMark Detect — Chrome Web Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; — JSON, Base64, URL Encode, Diff, Timestamp, and JWT tools, all in your browser&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Changed in v1.2.0
&lt;/h2&gt;

&lt;p&gt;Three new features.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Inline Preview
&lt;/h3&gt;

&lt;p&gt;You can now see conversion results right inside the popup. JSON gets formatted, Base64 gets decoded, timestamps get converted. No need to open the full tool when you just want a quick peek.&lt;/p&gt;

&lt;p&gt;Copy button included — paste the result anywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Page Scan
&lt;/h3&gt;

&lt;p&gt;Hit "Scan this page" in the popup, and it scans all &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; elements on the page. For blocks containing JSON, Base64, URL Encoded text, Unified Diff, or Unix Timestamps, action buttons are injected below the code block.&lt;/p&gt;

&lt;p&gt;No copying. No selecting. Just click the button under the code block you're interested in.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. JWT Detection
&lt;/h3&gt;

&lt;p&gt;v1.2.0 adds JWT (JSON Web Token) detection. When it finds a token starting with &lt;code&gt;eyJhbGciOi...&lt;/code&gt;, it decodes the header and payload right in the inline preview. With expiration badge.&lt;/p&gt;

&lt;p&gt;JWT is Base64url-encoded, so it conflicts with existing Base64 detection. The fix was simple. Since JWT headers encode &lt;code&gt;{"alg":...}&lt;/code&gt; in Base64url, they always start with &lt;code&gt;eyJ&lt;/code&gt;. A regex checks this first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// JWT detection — prioritized over Base64&lt;/span&gt;
&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="sr"&gt;/^eyJ&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;eyJ&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;eyJ&lt;/code&gt; + three dot-separated parts is enough to accurately separate JWT from Base64. Both header and payload start with &lt;code&gt;eyJ&lt;/code&gt; (= Base64url of &lt;code&gt;{&lt;/code&gt;), which is a structural characteristic of JWT. Just prioritizing JWT → Base64 in the detection order was enough for coexistence.&lt;/p&gt;

&lt;p&gt;Decoded results link to &lt;a href="https://jwt.puremark.app" rel="noopener noreferrer"&gt;JWT Decoder&lt;/a&gt; for claim explanations and expiration visualization.&lt;/p&gt;




&lt;h2&gt;
  
  
  Content Script Blew Up on &lt;code&gt;import&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Page scanning requires injecting code into the page as a Content Script. The detection logic was already implemented in &lt;code&gt;detectors.ts&lt;/code&gt; on the popup side, so I just &lt;code&gt;import&lt;/code&gt;ed it —&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;Uncaught&lt;/span&gt; &lt;span class="nx"&gt;SyntaxError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cannot&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;statement&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Content Scripts don't load as ES Modules.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest V3 Service Workers support ES Modules, but Content Scripts still use the legacy script injection approach. &lt;code&gt;import&lt;/code&gt; statements don't work.&lt;/p&gt;

&lt;p&gt;To share detection logic between popup and Content Script, a separate build for the Content Script is needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.content.ts — Content Script dedicated build&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;emptyOutDir&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="c1"&gt;// Don't wipe the main build output&lt;/span&gt;
    &lt;span class="na"&gt;lib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&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/content/scanner.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;formats&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;iife&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// ← Self-contained bundle&lt;/span&gt;
      &lt;span class="na"&gt;fileName&lt;/span&gt;&lt;span class="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-scanner.js&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bundling as &lt;code&gt;iife&lt;/code&gt; (Immediately Invoked Function Expression) eliminates all &lt;code&gt;import&lt;/code&gt; statements and inlines everything into a single file. The build command becomes two-stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vite build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; vite build &lt;span class="nt"&gt;--config&lt;/span&gt; vite.config.content.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First build for popup and Service Worker, second for Content Script. Forget &lt;code&gt;emptyOutDir: false&lt;/code&gt; and the first build's output gets nuked.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Smart Quote Battle
&lt;/h2&gt;

&lt;p&gt;The real reason for implementing page scan was that clipboard-based detection was unreliable.&lt;/p&gt;

&lt;p&gt;Copying JSON from Japanese tech blogs produced this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;←&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;What&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;you&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;see&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;screen&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;←&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;What&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;actually&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lands&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;clipboard&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Indistinguishable. But &lt;code&gt;""&lt;/code&gt; (U+201C, U+201D) is not &lt;code&gt;""&lt;/code&gt; (U+0022). &lt;code&gt;JSON.parse()&lt;/code&gt; fails immediately.&lt;/p&gt;

&lt;p&gt;The fix: Unicode normalization before detection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeQuotes&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;text&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;201C&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201D&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201E&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201F&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2033&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2036&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;FF02&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;2018&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2019&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201A&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201B&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2032&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2035&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;FF07&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;7 varieties of double quote variants and 7 single quote variants. This alone dramatically improved JSON detection from Japanese blogs.&lt;/p&gt;

&lt;p&gt;Plus trailing comma removal and &lt;code&gt;{ {...}, {...} }&lt;/code&gt; → &lt;code&gt;[{...}, {...}]&lt;/code&gt; array bracket correction. Rescuing the "slightly broken JSON" that blog CMSes generate.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Page scan reads raw DOM text directly, bypassing CMS smart quote conversion. Try it with &lt;a href="https://chromewebstore.google.com/detail/puremark-detect/llmjencpmkmfflggbgndphopclcbkdhi" rel="noopener noreferrer"&gt;PureMark Detect&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  "Hello World" Detected as Base64
&lt;/h2&gt;

&lt;p&gt;After implementing page scan and JWT detection, I tested on various sites.&lt;/p&gt;

&lt;p&gt;One page had &lt;code&gt;Hello World&lt;/code&gt; in a code block. Detected as Base64.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Hello World" is not Base64.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But character-wise, &lt;code&gt;HelloWorld&lt;/code&gt; matches &lt;code&gt;[A-Za-z0-9+/]&lt;/code&gt;. &lt;code&gt;atob()&lt;/code&gt; doesn't throw either. The two-stage filter of regex + actual decode verification wasn't enough.&lt;/p&gt;

&lt;p&gt;Final solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isBase64&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;trimmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+ &lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Exclude English text&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/\s]&lt;/span&gt;&lt;span class="sr"&gt;+=*$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Minimum length raised to 16 characters, and "English word + space + English word" pattern rejected upfront. Page scan processes many code blocks, so it needs to be far stricter about false positives than clipboard detection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before (v1.0):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Select text → Copy → Click icon → Detect → Open tool&lt;/li&gt;
&lt;li&gt;Smart quote contamination breaks detection&lt;/li&gt;
&lt;li&gt;Must open full tool to see results&lt;/li&gt;
&lt;li&gt;JWT misdetected as Base64&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After (v1.2):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Scan this page" → Auto-detect code blocks → Button click to jump to tool&lt;/li&gt;
&lt;li&gt;No copying. No selecting. Reads raw DOM, bypassing smart quote issues&lt;/li&gt;
&lt;li&gt;Inline preview shows conversion results without leaving the page&lt;/li&gt;
&lt;li&gt;JWT correctly detected with header/payload decode display&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Technically, the four key challenges were: Content Script IIFE builds, Unicode normalization, Base64 false positive prevention, and JWT detection priority design. All born from "it broke when I actually used it" — none predictable at design time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://chromewebstore.google.com/detail/puremark-detect/llmjencpmkmfflggbgndphopclcbkdhi" rel="noopener noreferrer"&gt;PureMark Detect — Chrome Web Store&lt;/a&gt; | &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; — Zero-click start, simply.&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>jwt</category>
    </item>
    <item>
      <title>I Built a JWT Decoder and Lost Half a Day to atob()</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Wed, 11 Mar 2026 10:48:17 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/i-built-a-jwt-decoder-and-lost-half-a-day-to-atob-2cok</link>
      <guid>https://forem.com/tommy_worklab/i-built-a-jwt-decoder-and-lost-half-a-day-to-atob-2cok</guid>
      <description>&lt;p&gt;During development, I constantly need to check what's inside a JWT.&lt;/p&gt;

&lt;p&gt;Every time, I'd open jwt.io, paste the token, read the payload, and switch back to the editor. This back-and-forth was surprisingly tedious. And pasting auth tokens into an external site always felt a bit uncomfortable.&lt;/p&gt;

&lt;p&gt;"Let me build a decoder that works locally."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The finished decoder is here. It auto-detects JWTs from your clipboard and decodes them.&lt;br&gt;
👉 &lt;a href="https://jwt.puremark.app" rel="noopener noreferrer"&gt;jwt.puremark.app&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A JWT is just three parts joined by &lt;code&gt;.&lt;/code&gt;. Base64-decode them and you can see the contents. Should be easy — or so I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: &lt;code&gt;atob()&lt;/code&gt; Doesn't Work
&lt;/h2&gt;

&lt;p&gt;I extracted the payload part of a JWT and passed it to JavaScript's &lt;code&gt;atob()&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; DOMException: String contains an invalid character&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't work.&lt;/p&gt;

&lt;p&gt;After investigating, I found the cause. &lt;strong&gt;JWT uses Base64url, not Base64.&lt;/strong&gt; They look similar but are different.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Standard Base64&lt;/th&gt;
&lt;th&gt;Base64url&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;62nd character&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;63rd character&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The characters are replaced with URL-safe alternatives. &lt;code&gt;atob()&lt;/code&gt; only accepts standard Base64, so passing Base64url directly throws an error.&lt;/p&gt;

&lt;p&gt;The fix is just two &lt;code&gt;replace&lt;/code&gt; calls.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; {"sub":"1234567890","name":"John Doe","iat":1516239022}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. "Is that all?" I thought — but it's the kind of trap you'd be stuck on forever if you didn't know about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: The Padding Problem Strikes Again
&lt;/h2&gt;

&lt;p&gt;I felt safe after fixing Trap 1. Then I tried a different JWT and got the same error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DOMException: String contains an invalid character
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same error. But this time there were no &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt; characters.&lt;/p&gt;

&lt;p&gt;The cause was &lt;strong&gt;padding&lt;/strong&gt;. Standard Base64 pads strings to a multiple of 4 with &lt;code&gt;=&lt;/code&gt;. But Base64url omits the &lt;code&gt;=&lt;/code&gt;. &lt;code&gt;atob()&lt;/code&gt; sometimes rejects input without proper padding.&lt;/p&gt;

&lt;p&gt;The fix is straightforward. Add &lt;code&gt;=&lt;/code&gt; based on the remainder when dividing the string length by 4.&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;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&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;pad&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remainder of 2 → &lt;code&gt;==&lt;/code&gt;, remainder of 3 → &lt;code&gt;=&lt;/code&gt;. That's it. But the time I spent testing every JWT and wondering "why does only this one fail?" was definitely half a day.&lt;/p&gt;

&lt;h3&gt;
  
  
  UTF-8 Support: Enter &lt;code&gt;TextDecoder&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;On top of that, JWTs with Japanese characters in the payload produced garbled text. &lt;code&gt;atob()&lt;/code&gt; treats everything as Latin-1, breaking multibyte characters.&lt;/p&gt;

&lt;p&gt;The final decode function looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;base64UrlDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&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;pad&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&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;Trap 1 (character conversion) + Trap 2 (padding) + UTF-8 support. All together, 13 lines. It took half a day to arrive at these 13 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Decision: No Signature Verification
&lt;/h2&gt;

&lt;p&gt;"A JWT decoder that doesn't verify signatures?"&lt;/p&gt;

&lt;p&gt;Correct. The reason is clear: &lt;strong&gt;the purpose of a decoder is to inspect the contents.&lt;/strong&gt; Signature verification is the server's job. Holding secret keys on the client side is a security risk, and the algorithms vary widely.&lt;/p&gt;

&lt;p&gt;However, there's one absolute line to draw:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Decode ≠ Trust&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Just because you can decode a JWT and see its contents doesn't mean you should trust them. Without signature verification, tampering cannot be detected. A decoder is strictly a development inspection tool. If you use decoded results for authentication or authorization decisions, always verify signatures on the server side.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bonus: Auto Claim Descriptions and Expiry Badges
&lt;/h2&gt;

&lt;p&gt;Once decoding works, the next thing you want is "what does this claim mean?" &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;iat&lt;/code&gt;, &lt;code&gt;iss&lt;/code&gt; — JWT standard claims use abbreviations that are hard to remember.&lt;/p&gt;

&lt;p&gt;I added descriptions for 15 major claims.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issuer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Audience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expiration Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nbf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not Before&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issued At&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JWT ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For timestamp claims like &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;nbf&lt;/code&gt;, and &lt;code&gt;iat&lt;/code&gt;, I display not just Unix seconds but also &lt;strong&gt;ISO 8601 format + relative time&lt;/strong&gt; ("3 hours ago", "in 5 days").&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Expiry badges are also implemented. Green for valid, red for expired, yellow for not-yet-valid. No need to paste into jwt.io — check your token's status at a glance at &lt;a href="https://jwt.puremark.app" rel="noopener noreferrer"&gt;jwt.puremark.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Decoded payloads can also be formatted with &lt;a href="https://json.puremark.app" rel="noopener noreferrer"&gt;JSON Formatter&lt;/a&gt; or the original encoded string can be checked with &lt;a href="https://base64.puremark.app" rel="noopener noreferrer"&gt;Base64 Decoder&lt;/a&gt;. Cross-tool navigation links are included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / After
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JWT inspection&lt;/td&gt;
&lt;td&gt;Copy-paste to jwt.io. Token sent to external site&lt;/td&gt;
&lt;td&gt;Runs locally. Auto-detects from clipboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;atob()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pass Base64url directly → error&lt;/td&gt;
&lt;td&gt;Insert &lt;code&gt;-&lt;/code&gt;→&lt;code&gt;+&lt;/code&gt;, &lt;code&gt;_&lt;/code&gt;→&lt;code&gt;/&lt;/code&gt; conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Padding&lt;/td&gt;
&lt;td&gt;Some JWTs fail mysteriously&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;% 4&lt;/code&gt; remainder and append &lt;code&gt;=&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claims&lt;/td&gt;
&lt;td&gt;Look up &lt;code&gt;exp&lt;/code&gt; every time&lt;/td&gt;
&lt;td&gt;Auto descriptions + relative time display&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lessons from half a wasted day are condensed into a 13-line function. I hope this article saves you that half day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JWT Decoder → &lt;a href="https://jwt.puremark.app" rel="noopener noreferrer"&gt;jwt.puremark.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7519" rel="noopener noreferrer"&gt;RFC 7519 - JSON Web Token (JWT)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc4648" rel="noopener noreferrer"&gt;RFC 4648 - Base16, Base32, Base64 Data Encodings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/atob" rel="noopener noreferrer"&gt;atob() - Web API | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder" rel="noopener noreferrer"&gt;TextDecoder - Web API | MDN&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>jwt</category>
      <category>javascript</category>
      <category>base64</category>
      <category>security</category>
    </item>
    <item>
      <title>I Built a Unix Timestamp Converter and Stepped on 3 JavaScript Date API Landmines</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sun, 08 Mar 2026 09:23:43 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/i-built-a-unix-timestamp-converter-and-stepped-on-3-javascript-date-api-landmines-5d2f</link>
      <guid>https://forem.com/tommy_worklab/i-built-a-unix-timestamp-converter-and-stepped-on-3-javascript-date-api-landmines-5d2f</guid>
      <description>&lt;p&gt;An API response returns &lt;code&gt;1709654400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Can you tell what date that is at a glance? I couldn't. Every time, I'd google "Unix Timestamp converter" and paste it into some online tool. That tiny friction adds up, so I decided to build my own converter.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Try it out: &lt;a href="https://timestamp.puremark.app" rel="noopener noreferrer"&gt;timestamp.puremark.app&lt;/a&gt; — zero-click timestamp conversion, right in your browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I got it working, but not before stepping on 3 landmines in JavaScript's &lt;code&gt;Date&lt;/code&gt; API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landmine 1: &lt;code&gt;new Date(1709654400)&lt;/code&gt; Returns 1970
&lt;/h2&gt;

&lt;p&gt;I started confidently. Just pass the timestamp to &lt;code&gt;new Date()&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;date&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1709654400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; "1970-01-20T21:34:14.400Z"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Way off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cause was simple: &lt;strong&gt;JavaScript's &lt;code&gt;Date&lt;/code&gt; expects milliseconds&lt;/strong&gt;, but Unix timestamps are in seconds. You need to multiply by 1000.&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;date&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1709654400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; "2024-03-05T00:00:00.000Z"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But should users have to think about whether they're entering seconds or milliseconds? No. I implemented &lt;strong&gt;auto-detection by digit count&lt;/strong&gt;: 10 digits = seconds, 13 digits = milliseconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toMillis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{10}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{13}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;NaN&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;Three lines. But without them, every input becomes a guessing game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landmine 2: Hyphens and Slashes Parse Differently
&lt;/h2&gt;

&lt;p&gt;Next, I added date-string-to-timestamp conversion. Type &lt;code&gt;2024-03-05&lt;/code&gt;, get the Unix timestamp. Should be simple.&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-03-05&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; "2024-03-05T00:00:00.000Z" (UTC)&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024/03/05&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; "2024-03-04T15:00:00.000Z" (JST midnight = UTC previous day)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same date, different separators, &lt;strong&gt;completely different results&lt;/strong&gt;. Hyphens get parsed as UTC (ISO 8601), slashes get parsed as local time (implementation-dependent).&lt;/p&gt;

&lt;p&gt;MDN says it clearly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Parsing date strings with the &lt;code&gt;Date&lt;/code&gt; constructor is &lt;strong&gt;strongly discouraged&lt;/strong&gt; due to browser differences and inconsistencies.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The lesson: &lt;strong&gt;never trust &lt;code&gt;new Date()&lt;/code&gt; with non-ISO-8601 strings.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Landmine 3: The Timezone Display Maze
&lt;/h2&gt;

&lt;p&gt;Once conversion worked, I wanted a timezone comparison table — "What time is this in Tokyo? New York? Berlin?"&lt;/p&gt;

&lt;p&gt;My first attempt was &lt;code&gt;getTimezoneOffset()&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;offset&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getTimezoneOffset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// JST: -540&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The sign is inverted.&lt;/strong&gt; JST is UTC+9, but &lt;code&gt;getTimezoneOffset()&lt;/code&gt; returns -540 minutes (= -9 hours). Plus, it only returns the user's local timezone — you can't get arbitrary timezones with it.&lt;/p&gt;

&lt;p&gt;The solution: &lt;strong&gt;&lt;code&gt;Intl.DateTimeFormat&lt;/code&gt;&lt;/strong&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;date&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-03-05T10:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&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-US&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;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Asia/Tokyo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;hour12&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="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;second&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; "03/05/2024, 19:00:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just pass an IANA timezone identifier to the &lt;code&gt;timeZone&lt;/code&gt; option. No external libraries needed. I implemented a 10-timezone comparison table covering UTC, JST, EST, PST, CET, GMT, IST, CST, AEST, and BRT.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;See it in action: &lt;a href="https://timestamp.puremark.app" rel="noopener noreferrer"&gt;timestamp.puremark.app&lt;/a&gt; shows your timestamp across 10 cities at a glance. Each row has a copy button for pasting into Slack or Jira.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Before / After
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Checking timestamps&lt;/td&gt;
&lt;td&gt;Google → copy-paste to some online tool&lt;/td&gt;
&lt;td&gt;Just open a browser tab. Auto-reads from clipboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seconds vs milliseconds&lt;/td&gt;
&lt;td&gt;Count digits manually&lt;/td&gt;
&lt;td&gt;Auto-detected. Don't even think about it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date string parsing&lt;/td&gt;
&lt;td&gt;Trust &lt;code&gt;new Date()&lt;/code&gt;, get bitten by browser differences&lt;/td&gt;
&lt;td&gt;Know that non-ISO-8601 is unreliable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timezones&lt;/td&gt;
&lt;td&gt;Confused by &lt;code&gt;getTimezoneOffset()&lt;/code&gt; sign&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; with 10-city display&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three landmines are "obvious once you know" but can cost you hours if you don't. Hope this article saves you that time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timestamp converter → &lt;a href="https://timestamp.puremark.app" rel="noopener noreferrer"&gt;timestamp.puremark.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date" rel="noopener noreferrer"&gt;Date - JavaScript | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat" rel="noopener noreferrer"&gt;Intl.DateTimeFormat - JavaScript | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.iana.org/time-zones" rel="noopener noreferrer"&gt;IANA Time Zone Database&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Almost Signed Up for API Access. Claude Code Was Already in My $20 Pro Plan.</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sat, 07 Mar 2026 22:22:56 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/i-almost-signed-up-for-api-access-claude-code-was-already-in-my-20-pro-plan-41h8</link>
      <guid>https://forem.com/tommy_worklab/i-almost-signed-up-for-api-access-claude-code-was-already-in-my-20-pro-plan-41h8</guid>
      <description>&lt;h2&gt;
  
  
  I Nearly Double-Paid
&lt;/h2&gt;

&lt;p&gt;Claude Code caught my attention. Call Claude from the terminal. Read and write files directly. Understand an entire codebase and make fixes across it.&lt;/p&gt;

&lt;p&gt;"I need this," I thought, and opened the Anthropic website. Claude Console, API keys, usage-based billing... &lt;strong&gt;"So it's a separate subscription on top of Pro,"&lt;/strong&gt; I assumed. $20/month plus API charges. I hesitated.&lt;/p&gt;

&lt;p&gt;A few days later, I re-read the official docs and spotted this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pro and Max plans now include access to both Claude on web, desktop, and mobile apps, as well as Claude Code in the terminal, all under &lt;strong&gt;one unified subscription&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;It was included in the Pro plan ($20/month).&lt;/strong&gt; No additional contract needed. No API key required.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;This article is for Claude Pro subscribers. If you're interested in practical Claude Code workflows, I wrote about that too:&lt;br&gt;
&lt;a href="https://zenn.dev/and_and/articles/claude-code-obsidianai" rel="noopener noreferrer"&gt;Claude Code x Obsidian: Turning AI Work Logs into a Knowledge Base&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Installation Is 3 Lines
&lt;/h2&gt;

&lt;p&gt;If you have Node.js 18+, there are exactly three things to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @anthropic-ai/claude-code  &lt;span class="c"&gt;# Install&lt;/span&gt;
claude                                     &lt;span class="c"&gt;# Launch (opens auth on first run)&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; Log in with your Pro plan account. That's it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key generation. No Console configuration. Just log in with your Pro account and start using it.&lt;/p&gt;

&lt;p&gt;Anticlimactically easy.&lt;/p&gt;




&lt;h2&gt;
  
  
  When You Hit the Limit, Don't Press "YES"
&lt;/h2&gt;

&lt;p&gt;The Pro plan has usage caps. If you're using Claude Code heavily, you'll eventually see this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You've reached your usage limit. Use API credits to continue?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If you press YES, you switch to usage-based billing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first I thought, "I don't have an API key, so I'm safe." Wrong.&lt;/p&gt;

&lt;p&gt;Pressing YES triggers the Claude Console (API management) sign-up flow. If you proceed, &lt;strong&gt;usage-based billing starts even without an explicit API key&lt;/strong&gt;. And Auto-reload (automatic credit purchase when balance runs low) may be on by default.&lt;/p&gt;

&lt;p&gt;In other words, there's a path from "hit limit -&amp;gt; YES -&amp;gt; sign up -&amp;gt; surprise charges."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What Happens When You Press YES&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No Console account&lt;/td&gt;
&lt;td&gt;Redirected to sign-up flow. No charges if you don't complete it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Console exists, Auto-reload off&lt;/td&gt;
&lt;td&gt;Consumes existing credit balance only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Console exists, Auto-reload on&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Balance depleted -&amp;gt; auto-purchase -&amp;gt; charges start&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  How to Stay Safe
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When the limit prompt appears, choose "No"&lt;/strong&gt; (just wait for the reset)&lt;/li&gt;
&lt;li&gt;As a precaution, visit &lt;a href="https://console.anthropic.com/settings/billing" rel="noopener noreferrer"&gt;console.anthropic.com/settings/billing&lt;/a&gt; and &lt;strong&gt;turn Auto-reload off&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. No charges beyond $20/month.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Curious what you can build with Claude Code? I built the developer tool suite &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; from Phase 0 through Phase 4 entirely with Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Claude Desktop vs. Claude Code
&lt;/h2&gt;

&lt;p&gt;"If I already have Claude Desktop, why do I need Claude Code?" I had the same question. Here's what I found:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Claude Desktop&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File access&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upload or paste into chat&lt;/td&gt;
&lt;td&gt;Reads and writes local files directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Codebase awareness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Feed files one at a time&lt;/td&gt;
&lt;td&gt;Automatically understands the entire project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Git operations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;td&gt;Commit, push — end to end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCP integration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Available with config&lt;/td&gt;
&lt;td&gt;Available with config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Interface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GUI chat&lt;/td&gt;
&lt;td&gt;Terminal CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest difference: &lt;strong&gt;whether it can directly touch your files&lt;/strong&gt;. With Claude Desktop, you manually feed files into the conversation. Claude Code uses &lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&gt;, and &lt;code&gt;Write&lt;/code&gt; tools to operate on local files directly. Because it understands the full codebase, it figures out "that other file needs to change too" on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Assumed Claude Code required a separate API contract&lt;/li&gt;
&lt;li&gt;Was mentally prepared for $20/month + API usage fees&lt;/li&gt;
&lt;li&gt;Kept putting off the setup as a result&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pro plan ($20/month) includes Claude Code — no extras&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Installation is 3 lines; authentication is just logging in with your Pro account&lt;/li&gt;
&lt;li&gt;As long as you say "No" at the usage limit prompt, no additional charges&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude Code is easier to get started with than you'd think. If you know someone hesitating because of the API pricing page, tell them: "It's already in your Pro plan."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.anthropic.com/en/docs/claude-code/overview" rel="noopener noreferrer"&gt;Claude Code Official Docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; — Zero-click simplicity for developers.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.anthropic.com/en/articles/11145838-using-claude-code-with-a-pro-or-max-plan" rel="noopener noreferrer"&gt;Using Claude Code with Pro or Max plan (Anthropic)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/docs/claude-code/overview" rel="noopener noreferrer"&gt;Claude Code Official Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://console.anthropic.com/settings/billing" rel="noopener noreferrer"&gt;Claude Console Billing Settings&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Implemented Myers Diff in 130 Lines, Then Lost Half a Day to an Off-by-One Bug</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sat, 07 Mar 2026 22:22:49 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/i-implemented-myers-diff-in-130-lines-then-lost-half-a-day-to-an-off-by-one-bug-545d</link>
      <guid>https://forem.com/tommy_worklab/i-implemented-myers-diff-in-130-lines-then-lost-half-a-day-to-an-off-by-one-bug-545d</guid>
      <description>&lt;h2&gt;
  
  
  I Didn't Want Another Dependency
&lt;/h2&gt;

&lt;p&gt;I could have installed a &lt;code&gt;diff&lt;/code&gt; library and been done in five minutes. I know.&lt;/p&gt;

&lt;p&gt;But my developer tool suite &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; has a strict &lt;strong&gt;zero-dependency&lt;/strong&gt; policy. The JSON Formatter, Base64 decoder, URL Encoder — all hand-written. Letting the Diff Checker be the one exception didn't sit right.&lt;/p&gt;

&lt;p&gt;Besides, I use &lt;code&gt;git diff&lt;/code&gt; every single day, and I couldn't explain what was actually happening behind the scenes. That bothered me.&lt;/p&gt;

&lt;p&gt;Turns out, it's a 1986 algorithm by Eugene Myers. &lt;code&gt;git diff&lt;/code&gt;, the Unix &lt;code&gt;diff&lt;/code&gt; command — they all use it under the hood. Nearly 40 years old and still the standard. When I implemented it, it fit in &lt;strong&gt;about 130 lines of TypeScript&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This article covers how the algorithm works and the off-by-one bug that cost me half a day.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Try the finished product here: &lt;a href="https://diff.puremark.app" rel="noopener noreferrer"&gt;PureMark Diff Checker&lt;/a&gt;&lt;br&gt;
Paste two blocks of text and see the diff highlighted instantly.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Reframing Diff as a Shortest Path Problem
&lt;/h2&gt;

&lt;p&gt;The core insight of Myers' algorithm is turning a diff problem into a &lt;strong&gt;shortest-path problem on a graph&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Given two texts A (old) and B (new), imagine a grid with A on the x-axis and B on the y-axis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     B[0]  B[1]  B[2]  B[3]
      |     |     |     |
  (0,0)--&amp;gt;(1,0)--&amp;gt;(2,0)--&amp;gt;(3,0)--&amp;gt;(4,0)
    |  \    |  \    |       |       |
    v   \   v   \   v       v       v
  (0,1)--&amp;gt;(1,1)--&amp;gt;(2,1)--&amp;gt;(3,1)--&amp;gt;(4,1)
    |       |  \    |  \    |       |
    v       v   \   v   \   v       v
  (0,2)--&amp;gt;(1,2)--&amp;gt;(2,2)--&amp;gt;(3,2)--&amp;gt;(4,2)

-&amp;gt; Right  = Delete (remove a line from A)
|  Down   = Insert (add a line from B)
\  Diagonal = Match (cost 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The shortest path from (0,0) to the bottom-right corner gives you the minimal edit sequence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Diagonal moves cost zero — when lines match, you skip ahead for free. That's the source of Myers' efficiency.&lt;/p&gt;

&lt;p&gt;The algorithm increments the edit distance d = 0, 1, 2, ... one step at a time, recording the farthest reachable point at each step. Once it reaches the goal, it backtracks to reconstruct the edit sequence.&lt;/p&gt;

&lt;p&gt;This "shortest path on a coordinate grid" mental model makes both the algorithm and the implementation far more approachable.&lt;/p&gt;




&lt;h2&gt;
  
  
  130 Lines. It Worked. Or So I Thought.
&lt;/h2&gt;

&lt;p&gt;Here's the core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;myers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;EditOp&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;m&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;v&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;Int32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;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;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Int32Array&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;  &lt;span class="c1"&gt;// Snapshot the V array&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;y&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;backtrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;backtrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key points:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Int32Array&lt;/code&gt; makes &lt;code&gt;slice()&lt;/code&gt; snapshots fast&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;trace&lt;/code&gt; array stores V state at each step — the key to backtracking&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;while&lt;/code&gt; loop (diagonal follow) skips matching lines at zero cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested it. Short text diffs — perfect. JSON comparisons — no issues. Longer code blocks — looked correct.&lt;/p&gt;

&lt;p&gt;I deployed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bug Showed Up the Next Day
&lt;/h2&gt;

&lt;p&gt;Long text, and the diff display was wrong. Deletions and insertions were in the wrong positions.&lt;/p&gt;

&lt;p&gt;Short texts worked fine. But as line counts grew, the path drifted. Classic &lt;strong&gt;off-by-one&lt;/strong&gt; smell.&lt;/p&gt;

&lt;p&gt;I traced through the backtrack function — the part that walks backward from the goal, reconstructing "which diagonal were we on at each step." The problem was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const v = trace[d - 1];  // Wrong
&lt;/span&gt;&lt;span class="gi"&gt;+ const v = trace[d];      // Correct
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why I got it wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;trace.push(v.slice())&lt;/code&gt; runs at the &lt;strong&gt;beginning&lt;/strong&gt; of each loop iteration. So &lt;code&gt;trace[d]&lt;/code&gt; holds "the V state before step d starts" — which is the same as "the V state after step d-1 completes."&lt;/p&gt;

&lt;p&gt;To unwind step d, you need the state at the start of step d — that's &lt;code&gt;trace[d]&lt;/code&gt;, not &lt;code&gt;trace[d-1]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trap is the intuition that "&lt;code&gt;trace[d]&lt;/code&gt; was saved &lt;em&gt;during&lt;/em&gt; step d, so it must contain the state &lt;em&gt;after&lt;/em&gt; step d." Wrong. Because the save happens at the &lt;strong&gt;top of the loop&lt;/strong&gt;, it actually contains the state &lt;em&gt;before&lt;/em&gt; step d.&lt;/p&gt;

&lt;p&gt;With short texts, d stays small and the path drift doesn't surface. With long texts, d grows large, and a one-step offset snowballs into a visible bug.&lt;/p&gt;

&lt;p&gt;The scary thing about this bug: &lt;strong&gt;short test cases won't catch it.&lt;/strong&gt; I had unit tests, but the test data was too small. Bugs that only manifest with production-sized data are the most dangerous kind.&lt;/p&gt;

&lt;p&gt;After the fix, I added long-text test cases. All tests pass.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;See this implementation in action: &lt;a href="https://diff.puremark.app" rel="noopener noreferrer"&gt;PureMark Diff Checker&lt;/a&gt;&lt;br&gt;
Toggle between side-by-side and unified views to compare how diffs are displayed.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Bonus: Solving Key Order in JSON Diff
&lt;/h2&gt;

&lt;p&gt;Text diff alone has a problem with JSON.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{ "a": 1, "b": 2 }&lt;/code&gt; and &lt;code&gt;{ "b": 2, "a": 1 }&lt;/code&gt; are semantically identical, but a text comparison shows them as different. The solution: recursively sort keys before comparing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sortKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&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="nx"&gt;value&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="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sortKeys&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;sorted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;sort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sorted&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sortKeys&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;key&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JSON.parse()&lt;/code&gt; -&amp;gt; sort keys -&amp;gt; &lt;code&gt;JSON.stringify(null, 2)&lt;/code&gt; -&amp;gt; regular text diff. That's all it takes for key-order-independent structural comparison. In PureMark Diff Checker, there's a "JSON Diff" toggle for this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Diff was a black-box library&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Implemented in 130 lines&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No idea how the algorithm worked&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;"Shortest path on a grid" — simple mental model&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External dependency bloating the bundle&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zero dependencies, minimal bundle&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest lesson: the off-by-one bug wasn't in the algorithm logic itself — it was in how I &lt;em&gt;read&lt;/em&gt; the data structure. The &lt;code&gt;trace&lt;/code&gt; array's save timing (top of loop vs. end of loop) was the entire issue. Short test cases can't find it. Bugs that only show up with real-world data are the scariest.&lt;/p&gt;

&lt;p&gt;Before reaching for a library, try implementing it yourself. 130 lines of investment, and &lt;code&gt;git diff&lt;/code&gt; output went from "magic" to "something I actually understand."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://diff.puremark.app" rel="noopener noreferrer"&gt;PureMark Diff Checker&lt;/a&gt; | &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; — Zero-click simplicity for developers.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="http://www.xmailserver.org/diff2.pdf" rel="noopener noreferrer"&gt;An O(ND) Difference Algorithm and Its Variations (Eugene Myers, 1986)&lt;/a&gt; — The original paper&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/" rel="noopener noreferrer"&gt;The Myers diff algorithm (blog.jcoglan.com)&lt;/a&gt; — An excellent walkthrough series&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;PureMark&lt;/a&gt; — Developer tool suite&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>algorithms</category>
      <category>typescript</category>
      <category>git</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Claude Desktop to Claude Code: What I Learned Rebuilding My AI Dev Environment 3 Times</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sat, 07 Mar 2026 22:13:13 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/claude-desktop-to-claude-code-what-i-learned-rebuilding-my-ai-dev-environment-3-times-28m</link>
      <guid>https://forem.com/tommy_worklab/claude-desktop-to-claude-code-what-i-learned-rebuilding-my-ai-dev-environment-3-times-28m</guid>
      <description>&lt;h2&gt;
  
  
  I Had All the Tools. Nothing Worked.
&lt;/h2&gt;

&lt;p&gt;"I want to use AI to make my development faster."&lt;/p&gt;

&lt;p&gt;With that thought, I installed every tool I'd heard about — Cursor, Claude Desktop, Obsidian, GitHub. I configured MCP connections, grabbed API keys, designed directory structures, and mapped out workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then I tore it all down and rebuilt it. Three times.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Looking back, the reason is clear. &lt;strong&gt;I started with tools before deciding what I actually needed to do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article isn't a setup guide. It's the story of three configurations I actually lived through — what broke, what clicked, and &lt;strong&gt;why each transition happened&lt;/strong&gt;. If you've installed a bunch of AI tools and still feel like you're not getting the most out of them, this might resonate.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Building something with AI tools?&lt;/strong&gt; I built &lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;Pure Mark Annotate&lt;/a&gt; — a PWA image annotation tool — using the exact workflow described in this article. Zero friction, browser-based. The tool and the process are inseparable.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Two Configurations Compared
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Config A (Initial)&lt;/th&gt;
&lt;th&gt;Config B (Current)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Editor&lt;/td&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Claude Desktop&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge mgmt&lt;/td&gt;
&lt;td&gt;Obsidian (via MCP)&lt;/td&gt;
&lt;td&gt;Obsidian (direct file access)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Research &amp;amp; writing&lt;/td&gt;
&lt;td&gt;Claude only&lt;/td&gt;
&lt;td&gt;Gemini API integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git/GitHub&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Directly from Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent settings&lt;/td&gt;
&lt;td&gt;User Preferences&lt;/td&gt;
&lt;td&gt;CLAUDE.md&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost&lt;/td&gt;
&lt;td&gt;Pro $20&lt;/td&gt;
&lt;td&gt;Pro $20 (same)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bottom line: &lt;strong&gt;The tool choice mattered less than how clearly I'd defined my goal.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Drift Before Purpose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Phase 0: "I want AI to make development faster" (vague)
&lt;/h3&gt;

&lt;p&gt;That was the starting motivation. Combine Cursor with Claude Desktop and something amazing will happen, right?&lt;/p&gt;

&lt;p&gt;At this stage, I couldn't articulate &lt;em&gt;what&lt;/em&gt; to make faster. So even after installing everything, I kept thinking, "Okay, now what?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: "I want to accumulate knowledge in Obsidian" (somewhat specific)
&lt;/h3&gt;

&lt;p&gt;Knowledge from AI chat sessions disappeared every time the session ended. That felt like waste. "What if conversations automatically accumulated in Obsidian?" — that was the first real goal.&lt;/p&gt;

&lt;p&gt;→ Built Config A with Claude Desktop + mcp-obsidian&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: "I want to automate article writing too" + "I want AI to handle Git" (specific)
&lt;/h3&gt;

&lt;p&gt;Not just storing knowledge — I wanted to write articles from it, push to GitHub, and publish to Zenn (a developer blogging platform). Claude Desktop can't run terminal commands, so I hit a wall.&lt;/p&gt;

&lt;p&gt;→ Migrated to Config B with Claude Code + Gemini + GitHub&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every time the goal got sharper, the required setup changed.&lt;/strong&gt; That's why I rebuilt three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config A: Cursor + Claude Desktop + Obsidian
&lt;/h2&gt;

&lt;p&gt;Goal at the time: &lt;strong&gt;"Chat with AI while saving notes to Obsidian"&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What Worked
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Setup was intuitive.&lt;/strong&gt; Claude Desktop is install-and-login. Configure MCP server connections through the GUI, enable Obsidian's &lt;code&gt;local REST API&lt;/code&gt; plugin, and Claude Desktop can read/write Obsidian files. Simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The chat UI is approachable.&lt;/strong&gt; Even non-engineers can start immediately. "Save this to Obsidian" — and MCP handles it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Broke
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Problem 1: Files created in WSL were invisible to Obsidian
&lt;/h4&gt;

&lt;p&gt;This was the first and biggest trap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Created a file from WSL&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# Test"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/vault/test.md

&lt;span class="c"&gt;# → Obsidian (Windows side) can't see it!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason was simple: &lt;strong&gt;before configuring automount, WSL local paths and Windows-side Obsidian paths are completely different locations.&lt;/strong&gt; The file exists, but Obsidian can't see it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WSL:     ~/vault/test.md        ← file created here
Windows: C:\Users\...\Obsidian\ ← test.md doesn't exist here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"File exists != Obsidian can see it." It took embarrassingly long to figure that out.&lt;/p&gt;

&lt;h4&gt;
  
  
  Problem 2: MCP connections were flaky
&lt;/h4&gt;

&lt;p&gt;File operations through MCP worked... when they worked. Depending on when Obsidian's REST API plugin started up or the MCP connection state, writes would sometimes fail silently. "Saved successfully" — but nothing was saved.&lt;/p&gt;

&lt;h4&gt;
  
  
  Problem 3: Re-explaining rules to AI every session
&lt;/h4&gt;

&lt;p&gt;Claude Desktop has &lt;code&gt;User Preferences&lt;/code&gt; for persistent settings, but it has limits. I kept having to re-explain project-specific rules ("Use MCP tools for Obsidian writes," "The path is X") every session.&lt;/p&gt;

&lt;p&gt;I ended up writing rules like this in User Preferences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Obsidian Rules (Required)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; USE mcp-obsidian:obsidian_append_content
&lt;span class="p"&gt;-&lt;/span&gt; DO NOT create files in WSL local paths
&lt;span class="p"&gt;-&lt;/span&gt; Reason: Without automount, files end up in the wrong location
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked, but &lt;strong&gt;"teaching AI the rules every time" was itself the inefficiency&lt;/strong&gt; I was trying to eliminate.&lt;/p&gt;

&lt;h4&gt;
  
  
  Problem 4: Terminal access requires stacking more MCPs
&lt;/h4&gt;

&lt;p&gt;Claude Desktop can't run terminal commands on its own. You &lt;em&gt;can&lt;/em&gt; add a Local Shell MCP server or GitHub MCP server, but each new capability means another MCP in your &lt;code&gt;config.json&lt;/code&gt;. Obsidian needs mcp-obsidian, terminal needs Shell MCP, GitHub needs GitHub MCP...&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The more connections, the more instability.&lt;/strong&gt; I started spending time figuring out which MCP was running and which had silently dropped.&lt;/p&gt;

&lt;p&gt;Claude Code has file operations, terminal, and Git built in natively. That difference became decisive as the MCP stack grew.&lt;/p&gt;

&lt;p&gt;The original goal — "chat with AI while taking notes" — was well served. But &lt;strong&gt;when the goal shifted to "automate the entire dev workflow," the MCP-stacking approach hit its ceiling.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Config B: Cursor + Claude Code + Gemini + Obsidian + GitHub
&lt;/h2&gt;

&lt;p&gt;The goal changed: &lt;strong&gt;"Run my entire dev workflow through AI"&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write code&lt;/li&gt;
&lt;li&gt;Run tests and builds&lt;/li&gt;
&lt;li&gt;Accumulate knowledge in Obsidian&lt;/li&gt;
&lt;li&gt;Write articles and publish them&lt;/li&gt;
&lt;li&gt;Manage GitHub issues and PRs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this, &lt;strong&gt;from one environment, in conversation with AI.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What Worked
&lt;/h3&gt;

&lt;h4&gt;
  
  
  1. Direct Obsidian Access — Finally Stable
&lt;/h4&gt;

&lt;p&gt;Claude Code runs inside WSL. After configuring automount (bind mount), it can Read/Edit/Write directly to &lt;code&gt;~/vault/&lt;/code&gt;. No MCP intermediary means no flaky connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Config A (Claude Desktop)
Claude → MCP connection → Obsidian REST API → file operation
                        ↑ This was unreliable

# Config B (Claude Code)
Claude Code → Read/Edit/Write → ~/vault/
              ↑ Native WSL operation. Stable.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  2. CLAUDE.md — The Project's Memory
&lt;/h4&gt;

&lt;p&gt;This was the single biggest improvement in Config B.&lt;/p&gt;

&lt;p&gt;Place a &lt;code&gt;CLAUDE.md&lt;/code&gt; file in your project root, and Claude Code automatically reads it at the start of every session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# CLAUDE.md (excerpt)&lt;/span&gt;
&lt;span class="gu"&gt;## Obsidian Rules&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Use Read/Edit/Write tools for direct file access (preferred)
&lt;span class="p"&gt;-&lt;/span&gt; Use mcp-obsidian only as fallback

&lt;span class="gu"&gt;## Article Writing Workflow&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Gemini research → outline review → Gemini draft → Claude review → publish

&lt;span class="gu"&gt;## GitHub Rules&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Always verify remote is private before git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config A's User Preferences were global — applied to every conversation. CLAUDE.md is &lt;strong&gt;per-project and version-controlled in Git.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The "re-explaining rules every session" problem was solved at the root.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Terminal Access — Just There
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Tell Claude Code "run the tests"&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt;

&lt;span class="c"&gt;# Git — directly&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat: new feature"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git push

&lt;span class="c"&gt;# GitHub CLI — directly&lt;/span&gt;
gh &lt;span class="nb"&gt;pr &lt;/span&gt;create &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"New feature"&lt;/span&gt; &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Summary..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chat and terminal are unified. The development flow never breaks when collaborating with AI.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. Claude + Gemini: Division of Labor
&lt;/h4&gt;

&lt;p&gt;Instead of making Claude do everything, I split responsibilities by strength.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Research, drafting, image generation&lt;/td&gt;
&lt;td&gt;Gemini API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality review, editorial judgment&lt;/td&gt;
&lt;td&gt;Claude (editor-in-chief)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code, Git operations&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge accumulation&lt;/td&gt;
&lt;td&gt;Obsidian (direct access)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When writing an article, Claude orchestrates the loop: "Have Gemini research → review the outline → have Gemini draft each section → Claude reviews." Automated.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Broke
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Problem 1: Automount Configuration — Trial and Error
&lt;/h4&gt;

&lt;p&gt;File sharing between WSL and Windows required automount (bind mount) setup. Get the &lt;code&gt;/etc/fstab&lt;/code&gt; entry wrong and WSL won't boot. Tread carefully.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Added to /etc/fstab (get it wrong and WSL won't start)
/mnt/c/Users/.../Obsidian/auto-empire ~/vault none noauto,x-systemd.automount,bind,... 0 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried symlinks first, but some AI agents refused to write through symlinks. Ended up with automount.&lt;/p&gt;

&lt;h4&gt;
  
  
  Problem 2: Learning to Write CLAUDE.md
&lt;/h4&gt;

&lt;p&gt;There's no established best practice for writing CLAUDE.md. I initially stuffed too much in, which made Claude Code's behavior erratic.&lt;/p&gt;

&lt;p&gt;Through trial and error, I landed on these principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep rules short and explicit&lt;/strong&gt; (move long explanations to separate files)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include path mapping tables&lt;/strong&gt; (so Claude Code doesn't guess wrong)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State "don't do this" rules explicitly&lt;/strong&gt; (e.g., "Never write code directly via GitHub API")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define trigger commands&lt;/strong&gt; (e.g., "write an article about X" → auto-launches the workflow)&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Problem 3: CLI Takes Getting Used To
&lt;/h4&gt;

&lt;p&gt;Compared to Claude Desktop's "type in a chat window and send" experience, Claude Code is terminal-based. Even inside Cursor's terminal, there's an initial "where do I talk to this thing?" moment.&lt;/p&gt;

&lt;p&gt;That said, Cursor's Claude Code extension provides an in-editor UI, so it's mostly a familiarity issue.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is the workflow I used to build &lt;a href="https://puremark.app" rel="noopener noreferrer"&gt;Pure Mark Annotate&lt;/a&gt;.&lt;/strong&gt; Claude Code orchestrating the dev loop, Obsidian accumulating decisions, Gemini handling research. The same system described here, running in production.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Your Goal Determines Your Setup
&lt;/h2&gt;

&lt;p&gt;After rebuilding three times, I'm certain of one thing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first step isn't setting up your environment. It's articulating what you want to do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Claude Code or Claude Desktop — which is better?" has no universal answer. &lt;strong&gt;It depends on your goal.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Recommended Config by Goal Clarity
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your Goal&lt;/th&gt;
&lt;th&gt;Recommended Setup&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chat with AI&lt;/td&gt;
&lt;td&gt;Claude Desktop alone&lt;/td&gt;
&lt;td&gt;Sufficient. No extra setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat + save notes to Obsidian&lt;/td&gt;
&lt;td&gt;Claude Desktop + mcp-obsidian&lt;/td&gt;
&lt;td&gt;One MCP connection does it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev + knowledge accumulation&lt;/td&gt;
&lt;td&gt;Cursor + Claude Code + Obsidian&lt;/td&gt;
&lt;td&gt;Direct file ops are stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automate the full dev workflow&lt;/td&gt;
&lt;td&gt;Cursor + Claude Code + Gemini + Obsidian + GitHub&lt;/td&gt;
&lt;td&gt;All-in. High setup cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key: &lt;strong&gt;start from the top row and work down.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you build the full-stack config first, you'll get buried in setup and lose sight of your actual goal. Wait until "what's missing" becomes obvious, then add the next layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Starting With Tools" Leads to Config Hell
&lt;/h3&gt;

&lt;p&gt;My failure pattern looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. "Claude Code is amazing" → Install
2. "MCP can connect to Obsidian" → Configure
3. "Gemini API is available too" → Add it
4. "Wait, what was I trying to do...?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting from the goal, it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. "I want to automate writing articles through to publishing" ← Goal
2. "Research with Gemini, editorial with Claude" ← Role split
3. "I need terminal access → Claude Code" ← Tool selection
4. "Knowledge in Obsidian → need WSL integration → automount" ← Env setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;You arrive at the same tools, but in reverse order.&lt;/strong&gt; When you start from the goal, each tool has a clear "why," and setup decisions stop being ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before (tool-first, no goal):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Install tools, drown in config&lt;/li&gt;
&lt;li&gt;"What was I doing again?" on repeat&lt;/li&gt;
&lt;li&gt;Rebuilt the environment 3 times&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After (goal-first, then config):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Say "write an article" and the pipeline runs from research to publication&lt;/li&gt;
&lt;li&gt;CLAUDE.md serves as the project's persistent memory&lt;/li&gt;
&lt;li&gt;Knowledge accumulates automatically from conversations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first thing you should do isn't installing the latest tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's writing down "What do I want AI to do for me?" in a notebook.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once that's clear, the right setup reveals itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write down "things I want AI to handle" in bullet points&lt;/strong&gt; — on paper or in Obsidian&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the config that matches your current goal clarity&lt;/strong&gt; from the table above&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add the next tool only when you feel what's missing&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Very few people need the full-stack setup. Start minimal.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/docs/claude-code/overview" rel="noopener noreferrer"&gt;Claude Code official docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.anthropic.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan" rel="noopener noreferrer"&gt;Using Claude Code with your Pro or Max plan&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://obsidian.md/" rel="noopener noreferrer"&gt;Obsidian&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://annotate.puremark.app" rel="noopener noreferrer"&gt;Pure Mark Annotate — Zero-friction image annotation in your browser&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Share Tool State via URL Hash Alone -- Designing 'Recipe URLs'</title>
      <dc:creator>tommy</dc:creator>
      <pubDate>Sat, 07 Mar 2026 22:13:04 +0000</pubDate>
      <link>https://forem.com/tommy_worklab/share-tool-state-via-url-hash-alone-designing-recipe-urls-2lop</link>
      <guid>https://forem.com/tommy_worklab/share-tool-state-via-url-hash-alone-designing-recipe-urls-2lop</guid>
      <description>&lt;h2&gt;
  
  
  Ever been in this situation?
&lt;/h2&gt;

&lt;p&gt;"Hey, can you check this Base64-decoded result for me?"&lt;/p&gt;

&lt;p&gt;You paste a Base64 string into Slack, tell your teammate "decode this and look at the output." They open a decoder, paste the string, hit decode. Three steps just to share a single transformation result.&lt;/p&gt;

&lt;p&gt;What if &lt;strong&gt;sending a URL instantly reproduced the same tool in the same state?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://base64.puremark.app/#d=SGVsbG8sIFdvcmxkIQ%3D%3D&amp;amp;m=encode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open that link and a Base64 tool launches in Encode mode with "Hello, World!" already entered and the encoded result displayed.&lt;/p&gt;

&lt;p&gt;I call this pattern a "Recipe URL." This article walks through the design decisions and implementation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Try it yourself&lt;/strong&gt; -- go to &lt;a href="https://base64.puremark.app" rel="noopener noreferrer"&gt;PureMark Base64&lt;/a&gt;, encode something, and hit "Share." A Recipe URL is generated and copied to your clipboard.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A design pattern for encoding tool state into URL hashes&lt;/li&gt;
&lt;li&gt;How to Base64-encode Unicode strings in a URL-safe way&lt;/li&gt;
&lt;li&gt;A serverless architecture for state sharing&lt;/li&gt;
&lt;li&gt;Concrete implementation code in React + TypeScript&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Basic TypeScript / React knowledge&lt;/li&gt;
&lt;li&gt;Understanding of URL structure (hash, search params)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why URL Hash?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  It's never sent to the server
&lt;/h3&gt;

&lt;p&gt;The hash portion of a URL (everything after &lt;code&gt;#&lt;/code&gt;) is not included in HTTP requests. This is defined in the RFC.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/path?query=value#fragment
                                      ^^^^^^^^
                                      Never sent to the server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means &lt;strong&gt;user data never touches a server. Period.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For web tools, whether input data gets sent to a server is a critical privacy concern. With URL hashes, there's no data leakage path by design.&lt;/p&gt;

&lt;h3&gt;
  
  
  No URL shortener needed
&lt;/h3&gt;

&lt;p&gt;A server-side approach (&lt;code&gt;example.com/share/abc123&lt;/code&gt;) requires a backend with a database to store shared states. That means infrastructure to build and maintain.&lt;/p&gt;

&lt;p&gt;The URL hash approach works on a fully static site. Cloudflare Pages or Vercel free tier -- no problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  It's just a URL
&lt;/h3&gt;

&lt;p&gt;A URL hash is part of a regular URL, so it works everywhere: Slack, Teams, email, GitHub issues. No special client required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipe URL Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hash Parameter Structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#d=&amp;lt;Base64-encoded data&amp;gt;&amp;amp;m=&amp;lt;mode&amp;gt;&amp;amp;o=&amp;lt;options&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Required&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input data (Base64-encoded)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mode (&lt;code&gt;encode&lt;/code&gt;, &lt;code&gt;decode&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tool-specific options (Base64-encoded JSON)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We use &lt;code&gt;URLSearchParams&lt;/code&gt; directly. No custom parser needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Size Limit
&lt;/h3&gt;

&lt;p&gt;Browser and proxy URL length limits vary, but in practice &lt;strong&gt;2,000 characters&lt;/strong&gt; is a safe upper bound. If the hash exceeds this, show an error to the user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_HASH_LENGTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you eventually need to handle larger data, you could compress with pako before Base64-encoding. But for now, simplicity wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Unicode-Safe Base64 Encoding
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;btoa()&lt;/code&gt; only handles the Latin-1 range (0x00--0xFF). Pass a Unicode string and you get &lt;code&gt;InvalidCharacterError&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Works fine&lt;/span&gt;
&lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;some unicode text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Might throw InvalidCharacterError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The solution: convert to UTF-8 bytes via &lt;code&gt;encodeURIComponent&lt;/code&gt; before passing to &lt;code&gt;btoa&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Unicode-safe Base64 encode */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="sr"&gt;/%&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9A-F&lt;/span&gt;&lt;span class="se"&gt;]{2})&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** Unicode-safe Base64 decode */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;decodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Encode:
  "Hello, World!"
  -&amp;gt; encodeURIComponent -&amp;gt; "Hello%2C%20World!"
  -&amp;gt; convert %XX to bytes -&amp;gt; binary string
  -&amp;gt; btoa -&amp;gt; Base64 string

Decode:
  Base64 string
  -&amp;gt; atob -&amp;gt; binary string
  -&amp;gt; each byte to %XX format -&amp;gt; "Hello%2C%20World!"
  -&amp;gt; decodeURIComponent -&amp;gt; "Hello, World!"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;You could also use &lt;code&gt;TextEncoder&lt;/code&gt; / &lt;code&gt;TextDecoder&lt;/code&gt;, but the approach above is shorter and has broader browser compatibility.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Encode / Decode Functions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;RecipeState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeRecipeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;params&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&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;options&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&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;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;o&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&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="nx"&gt;options&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&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;hash&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="nx"&gt;MAX_HASH_LENGTH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeRecipeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;RecipeState&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hash&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;clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;mode&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="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;o&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;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="nf"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;o&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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;Key design choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;encodeRecipeHash&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; when exceeding the 2,000-character limit (caller shows an error)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;decodeRecipeHash&lt;/code&gt; never throws -- it returns &lt;code&gt;null&lt;/code&gt; for any malformed input (safe even if someone hand-edits the URL)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Generating and Copying the Share URL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;copyShareUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encodeRecipeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&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;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}${&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;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;hash&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="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;window.location.origin&lt;/code&gt; means it works seamlessly in both development (&lt;code&gt;localhost:5173&lt;/code&gt;) and production (&lt;code&gt;base64.puremark.app&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Using It in React Components
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Restoring state on page load:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getRecipeFromUrl&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;recipe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;trackShareUrlOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;restoredFromRecipe&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;restoredFromRecipe&lt;/code&gt; flag prevents the clipboard auto-read feature (zero-click) from overwriting data that was shared via a Recipe URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Share button:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ShareButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ShareButtonProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;copied&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCopied&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleShare&lt;/span&gt; &lt;span class="o"&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;ok&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;copyShareUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setCopied&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="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCopied&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="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleShare&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;copied&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Copied!&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;Share&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;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;blockquote&gt;
&lt;p&gt;See this in action -- go to &lt;a href="https://json.puremark.app" rel="noopener noreferrer"&gt;PureMark JSON Formatter&lt;/a&gt;, format some JSON, and hit the "Share" button. A Recipe URL is generated and ready to send.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cross-Tool Linking
&lt;/h2&gt;

&lt;p&gt;Here's where Recipe URLs get really interesting: passing data between tools.&lt;/p&gt;

&lt;p&gt;For example, when the Base64 tool decodes something and detects it's JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openInJsonFormatter&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encodeRecipeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hash&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://json.puremark.app/#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hash&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puremark-json&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;Click "Open in JSON Formatter" and the JSON Formatter opens with the decoded result already formatted. No copy-paste needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data flow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Base64 Decoder (base64.puremark.app)
  -&amp;gt; Detects decoded output is JSON
  -&amp;gt; Shows "Open in JSON Formatter" button
  -&amp;gt; Click
  -&amp;gt; Opens json.puremark.app/#d=&amp;lt;JSON&amp;gt;&amp;amp;m=format
  -&amp;gt; JSON Formatter displays formatted result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data flows between tools on different subdomains, mediated only by a URL hash. No backend. No API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recipe URLs&lt;/strong&gt; encode tool state into URL hashes, enabling serverless state sharing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;encodeURIComponent&lt;/code&gt; + &lt;code&gt;btoa&lt;/code&gt; safely handles Unicode strings in Base64&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;URLSearchParams&lt;/code&gt; handles parsing -- no custom parser needed&lt;/li&gt;
&lt;li&gt;A 2,000-character limit keeps URLs practical, with explicit errors when exceeded&lt;/li&gt;
&lt;li&gt;The same pattern enables &lt;strong&gt;cross-tool linking&lt;/strong&gt; -- passing data between independent tools via URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This pattern applies to any static web app where you want to share input state: playgrounds, converters, formatters, calculators. If your tool has state worth sharing, Recipe URLs are a zero-infrastructure way to make it happen.&lt;/p&gt;

&lt;p&gt;See Recipe URLs in action: &lt;a href="https://base64.puremark.app" rel="noopener noreferrer"&gt;PureMark Base64&lt;/a&gt; / &lt;a href="https://json.puremark.app" rel="noopener noreferrer"&gt;PureMark JSON Formatter&lt;/a&gt; -- decode Base64, detect JSON, and hand it off to the formatter with one click.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://base64.puremark.app" rel="noopener noreferrer"&gt;PureMark Base64 Encoder/Decoder&lt;/a&gt; -- Recipe URLs in production&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://json.puremark.app" rel="noopener noreferrer"&gt;PureMark JSON Formatter&lt;/a&gt; -- Cross-tool linking with Base64&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/URL/hash" rel="noopener noreferrer"&gt;MDN: URL - hash&lt;/a&gt; -- URL hash specification&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/btoa" rel="noopener noreferrer"&gt;MDN: btoa()&lt;/a&gt; -- Base64 encoding constraints&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>react</category>
    </item>
  </channel>
</rss>
