86 tools. Zero backend. One person. Two hospital visits. $200 in tokens in 24 hours. This is what building in public actually looks like — not the highlight reel, but the raw commit history of a human being.
The Genesis: Why Would Anyone Build 86 Free Tools?
On March 18, 2026, I launched ToolKnit — a collection of free, browser-based utilities that require no signup, no uploads, and zero trust.
Today, it's 86 tools across 10 categories. PDF converters that run entirely client-side using PDF-lib.js. Image resizers powered by HTML5 Canvas. A Morse Code Translator with audible beep playback at adjustable WPM. A MIDI Keyboard with 8 synthesized timbres — Grand Piano, Electric Organ, Synth Lead, String Ensemble, Pipa, Guitar, Flute, and Music Box — all generated in real-time through the Web Audio API. A Lyric Visualizer that parses LRC files and syncs lyrics to audio waveforms. Even a drinking-game dare spinner with 300 dares across 6 categories, slot-machine text reveal animation, and confetti celebrations.
But here's what the landing page doesn't tell you: I just spent $200 in a single day on Claude Max tokens to migrate a handful of pages to a new bilingual architecture. I killed a feature after 48 hours of intensive debugging. And somewhere between the ffmpeg.wasm Worker CORS errors and the hospital visit, I learned that some bugs aren't in the code at all.
This isn't a success story. It's a build log.
Part I: The Stack — Why I Chose Boring Technology
Before I tell you about the features that lived and died, you need to understand the foundation. ToolKnit is pure static HTML + Tailwind CSS + Vanilla JS. No React. No Vue. No Next.js. No build step beyond Tailwind compilation.
Why?
- SEO-first: Static HTML means Google sees everything immediately. No hydration delays. No client-side routing. Every tool page is a complete, crawlable document with full Open Graph tags, Twitter Cards, and structured data.
- Zero JS framework bloat: Most tools work without JavaScript. Progressive enhancement is the default, not an afterthought.
-
Server costs near zero: The only PHP endpoints are
track.php(stats) andpublic-stats.php(aggregated metrics). All image processing, PDF manipulation, audio synthesis, and video transcoding happens in the browser. -
Deployment is
scp: One file, one command, zero CI/CD complexity. The Service Worker (toolknit-v119as of today) handles offline caching for returning visitors.
The tradeoff? Manual consistency across 86 pages. When you add a new footer link, you're editing 86 HTML files (or writing a Python script to do it, then verifying each one). That's why the bilingual architecture had to be a shared module, not a framework.
The PWA layer deserves its own mention. manifest.json declares the site as installable. The Service Worker precaches core assets. Users on slow connections get instant loads after the first visit. For a site with 86 tool pages, this matters more than any SPA transition animation.
Part II: The Tools That Lived — Engineering Notes from the Trenches
The MIDI Keyboard: 8 Timbres, 1 Web Audio Context
Building a browser-based MIDI keyboard sounds straightforward until you realize that "piano sound" isn't a single oscillator. Each timbre required distinct synthesis architecture:
Pipa (琵琶): Sawtooth + square wave generators with non-integer harmonics (5.04×, 7.01× fundamental) to simulate the metallic twang of plucked silk strings. A high-Q resonant lowpass filter shapes the body resonance, and a noise burst layer provides the finger-pick attack transient. Without that noise burst, it sounds like a cheap synth preset.
Guitar: Triangle wave fundamental + 6 overtone harmonics with body resonance simulation via 105 Hz and 215 Hz bandpass filters (acoustic cavity modes). The pluck noise layer is subtler than Pipa but essential — without it, notes sound like a keyboard patch, not a string.
Flute: Pure sine wave with faint 2nd harmonic, vibrato LFO at 5.5 Hz (8 cents depth, delayed onset at 0.3s to avoid startup wobble), and breath noise via highpass-filtered white noise at 3 kHz. The breath noise is what separates a "flute-like" synth from an actual flute.
Music Box: Non-integer bell partials (2.756×, 5.404×, 8.933× fundamental) for the metallic chime character, octave-above overtone, and a 400 Hz highpass filter to remove low-end rumble. Long natural decay — no ADSR envelope can fake this without custom release curves.
I also added a Quick Play Songs panel with Für Elise, Summer (Kikujiro), Ode to Joy, Canon in D, Castle in the Sky, and Always With Me. The keyboard auto-shifts octaves as you follow the key sequences. When I first tested it by playing Für Elise with the Z key, I couldn't stop grinning.
The Morse Code Translator: Audio, Flash, and International Alphabet
The Morse Code Translator was the first tool built entirely on the new bilingual blueprint. Two-way text↔Morse with adjustable WPM (5–40), audible beep playback via Web Audio API oscillator nodes, screen-flash signaling for accessibility, and the full international Morse alphabet (including accented characters and Prosigns like SOS, AR, SK).
The audio timing follows strict ITU-R standards: a "dit" is one time unit, a "dah" is three units, intra-character spacing is one unit, inter-character spacing is three units, and inter-word spacing is seven units. Getting the rhythm wrong makes Morse feel "mechanical" rather than "musical." The gap between characters matters as much as the beep itself.
The Lyric Visualizer: Web Audio API + Canvas + LRC Parsing
Shipped the same day I killed Live Photo Frame. Upload an MP3/WAV + LRC lyrics file for a full-screen immersive music player with real-time audio waveform visualization, time-synced fading lyrics, draggable progress bar, automatic ID3 cover art extraction, and a dark/light theme toggle.
The Web Audio API analyzer node pulls frequency data for the waveform canvas. LRC timestamp parsing converts [mm:ss.xx] markers into millisecond anchors. Lyrics fade in/out based on proximity to the current playback position. All in the browser. No server ever sees your audio files.
Spin the Dare: From Canvas Wheel to Slot Machine
The original Spin the Dare used a canvas roulette wheel. I rebuilt it with a slot-machine-style text reveal animation — the dare scrambles through random prompts rapidly, then eases to a dramatic stop with sound cues (scale transitions, pitch-shifted chimes) and confetti explosions.
Why replace a working wheel? Because the wheel had 300 segments at small radii — visually illegible. The slot-machine approach keeps every dare readable at any screen size, adds suspense through the scramble phase, and uses audio cues (pitch rising during scramble, resolving chord at stop) to create a more theatrical experience.
Part III: The Features That Died — When Technical Soundness Isn't Enough
Live Photo Frame: 48 Hours, 3 Fatal Flaws
The idea was elegant: frame iPhone Live Photos (HEIC + MOV pairs) and Android Motion Photos (JPG with embedded MP4) in 9:16 or 16:9 layouts with large rounded corners, black or white backgrounds, and export to static frame or video.
What I built:
- Client-side HEIC-to-JPEG conversion via
heic2any - ffmpeg.wasm (single-thread, ~32 MB one-time download) for H.265 MOV transcoding and MP4 encoding
- Auto-detection of iPhone Live Photo pairs, Android Motion Photos, and standalone video files
- Canvas rendering with configurable corner radii
Why I killed it after 48 hours:
Canvas
quadraticCurveTodraws parabolas, not circular arcs. At radii above ~60px, "rounded corners" morph into chamfered edges. The visual signature of the tool — those elegant large-radius corners — was mathematically impossible with the Canvas API. You needarc()with proper tangent calculations, notquadraticCurveTo().HEVC/H.265 videos from Android Motion Photos won't play natively in Chrome. ffmpeg.wasm transcoding at 1080×1920 resolution was agonizingly slow — 30+ seconds for a 3-second clip on a MacBook Pro. Users don't wait 30 seconds.
WeChat and messaging apps strip embedded video data from Motion Photos. The primary sharing pipeline (user receives Motion Photo via WeChat → opens in ToolKnit) was destroyed by app-level data stripping. The tool's core use case didn't exist in the real world.
Three strikes: visual quality, performance, and reliability. No amount of patching fixes a fundamentally flawed concept for the browser platform.
"A tool that 'almost works' is worse than no tool at all — it erodes trust. Ship what you're proud of, not what merely functions."
AI Pixel Art GIF: Twice Bitten
First attempt (June 9): Used DeepSeek API to generate pixel art from text prompts. Quality was garbage — LLMs don't understand pixel-level constraints.
Second attempt: Replaced DeepSeek with local Canvas pixelation (drawImage at reduced size, then drawImage back up with imageSmoothingEnabled = false) + gif.js encoding + local color analysis for smart animation selection. Fixed Worker CORS by hosting gif.worker.js locally. Fixed Canvas willReadFrequently warnings. Replaced DeepSeek API 400 errors with local analysis.
All solid engineering fixes. The tool "worked." But the output wasn't compelling. Pixel art GIFs demand either perfect algorithmic control or genuine artistic AI — we had neither.
Part IV: The $200 Day — Rebuilding 19 Pages in Bilingual Architecture
The Problem
Every tool page was English-only. Hardcoded strings everywhere. No SEO structure beyond basic meta tags. Footer layouts inconsistent across 80+ pages. For a site serving both English and Chinese speakers, this was a growth ceiling.
The Architecture
I designed a two-layer bilingual system:
| Layer | File | Scope |
|---|---|---|
| Shared UI | bilingual.js |
Nav, footer, breadcrumb, related tools, modals, feature pills |
| Page-specific | [tool].js |
Hero title/desc, tool UI labels, How It Works, Why Use, Pro Tips, FAQ |
| Translations | locales/zh.json |
Tool card titles/descriptions for Related Tools |
| Schemas | Inline JSON-LD |
SoftwareApplication + FAQPage + BreadcrumbList per page |
Key decisions:
-
English stays the default.
lang="en"is static. SEO is entirely English-first. Chinese is a UX enhancement that persists across sessions vialocalStorage. -
Zero impact on legacy pages.
bilingual.jsonly activates on pages that explicitly reference it. Old pages continue working exactly as before. -
Structured data is mandatory. Every bilingual page ships with
FAQPageJSON-LD that matches the visible FAQ accordion content exactly — question-for-question, answer-for-answer. - Cross-page language memory. Switch to Chinese on the Image Resizer, visit the PDF Compressor, it remembers your preference.
The Cost
Migrating 5 PDF tools + Spin the Dare + Morse Code Translator + Mic & Camera Test + 11 image converters burned through $100+ in AI tokens. The full June 12 session — which included writing the bilingual.js module, creating the template, and migrating the first batch — hit $200.
The architecture itself is genuinely beautiful. But every refinement comes with a price tag measured in dollars per hour. The remaining 67 legacy pages will migrate gradually. It isn't laziness; it's arithmetic.
Part V: The Migration War — 11 Image Tools, 11 Ways to Break
Over the past 72 hours, I migrated 11 image tools: Image to PDF, Compress Image, JPG to PNG, PNG to JPG, JPG to WebP, PNG to WebP, WebP to JPG, WebP to PNG, Image Grid Split, Image Crop, and Image Resizer.
Each migration follows the same pattern — and produces its own unique bug.
Bug 1: The Single Quote That Killed a Language Switcher
// Inside RESIZER_I18N.en:
why2Desc: 'Since everything runs on your machine, there's no upload delay...'
// ^ unescaped single quote
This unescaped ' terminated the string early. The JS parser hit s no upload delay... and threw SyntaxError: Unexpected identifier. Every function defined after this line — including window.switchLang — was never created.
User-facing result: Clicking the EN/中文 language toggle produced window.switchLang is not a function. The entire bilingual system failed silently.
Fix: there's → there\'s. Now I validate every i18n dictionary with node -e "$(cat file.html | sed -n '/RESIZER_I18N/,/^ };/p')" before deploying.
Bug 2: The Batch Script That Silently Missed Two Tips
I wrote Python scripts to auto-inject data-i18n attributes into HTML. The replacement logic:
('Use JPG at 80-85% quality for photos', ' data-i18n="tip2">Use JPG at 80-85% quality for photos')
But the actual HTML contained — (HTML entity) in some places and — (Unicode em-dash) in others. The string match failed silently. Two out of four Pro Tips never received data-i18n attributes.
User-facing result: In Chinese mode, Tips #2 and #4 remained in English while #1 and #3 translated correctly. Subtle. Embarrassing.
Rule I now enforce: Every batch migration script must include a verification pass:
expected_keys = ['tip1', 'tip2', 'tip3', 'tip4', 'why1Title', ...]
for key in expected_keys:
if f'data-i18n="{key}"' not in html:
print(f'MISSING: {key}')
Bug 3: innerHTML vs. SVG Children
<!-- WRONG: applyI18n() calls innerHTML on this button, wiping the SVG -->
<button data-i18n="ariaMenu" aria-label="Menu">
<svg>...</svg>
</button>
<!-- RIGHT: translate aria-label in swapLang(), keep SVG intact -->
<button id="hero-hamburger" aria-label="Menu">
<svg>...</svg>
</button>
applyI18n() assigns innerHTML for all non-input elements. If you put data-i18n on a parent containing an SVG icon, the icon disappears and gets replaced by raw text. The fix: keep data-i18n on sibling <span> elements, never on structural parents.
Bug 4: The Nginx Header That Broke Every Camera
The Microphone & Camera Test was failing on every device with Permissions policy violation. Users thought they had blocked access. They hadn't.
The Nginx server was sending: Permissions-Policy: camera=(), microphone=()
This header silently forbade all camera and microphone access before the browser even asked the user. The fix was a single Nginx config line change: camera=(self), microphone=(self). Users now see the normal browser permission prompt.
I also updated the deny-state copy in mic-camera-test.js (both English and Chinese) to explain that multi-device failures usually indicate a server header issue, not user settings.
Part VI: The Human Debug — When the Compiler Can't Help
On June 10, the developer's journal in our changelog reads:
"I woke up feeling off. Not the usual 'stayed up too late coding' off — something worse. Ended up at the hospital in the morning instead of at my desk. Lost half a day that I couldn't afford to lose. My body's been deteriorating for a while now. Too many late nights, too much coffee, not enough sleep, not enough care."
I shipped three tools that day. Only two made it. One of them was only 60% done.
On June 11, the entry isn't about code at all:
"When you pour real feeling into something and get indifference in return, the math never balances. You keep re-checking the equation hoping the answer changes. It doesn't. The hardest part isn't the leaving; it's that you genuinely cared and they genuinely didn't. But the only way past that truth is through it."
"To anyone who's ever felt like leaving this world because of one person, one heartbreak, one season of pain: don't. The world is wider than the wound. Green hills await wherever you roam."
The road trip was supposed to be June 12–15. Xinxiang, Xinyang, maybe Hunan. Two days of driving, windows down, nowhere to be. It got postponed. The keyboard won.
On June 8, I asked the AI Tarot: "Is there still a chance with her?" The three-card spread returned Judgement (Past) → The Devil (Present) → The Lovers (Future).
The AI read Judgement as a necessary awakening that had already happened — not a random ending, but a reckoning. The Devil nailed the present: caught in the grip of attachment, tethered by old patterns. And The Lovers: yes, there is a possibility — but only through authentic alignment, not compulsion.
It was unsettlingly accurate. The cards saw what I already knew but wouldn't admit.
Building in public means documenting the human bugs too. The insomnia. The weight loss (70.5 kg → 68.5 kg in two weeks). The appetite that vanished. The Douyin follow-then-unfollow at midnight. These are the entries that don't make it to the GitHub release notes but matter more than version bumps.
Part VII: The Numbers & What Honesty Looks Like
| Metric | Value |
|---|---|
| Total tools shipped |
86 (85 in /tools/ + 1 root feature page) |
| Tools killed after shipping | 2 (AI Pixel Art GIF ×2 attempts, Live Photo Frame) |
| Bilingual pages migrated | 19 |
| Legacy pages remaining | 67 |
| Blog posts published | 91 (84 guides + 9 Tool Tales) |
| Changelog entries | 46 |
| Service Worker version | toolknit-v119 |
| AI token spend (peak day) | $200 |
| Hospital visits during build | 2 |
| Lines of code written | I stopped counting after the insomnia started |
What's Next (The Arithmetic of Patience)
The remaining 67 legacy pages will migrate gradually. Each one requires:
- HTML structural overhaul (hero top-bar with language toggle, FAQ accordion, responsive footer-grid)
-
data-i18n/data-i18n-commonattribute injection across all static text - Page-specific i18n dictionary creation (30–60 keys per page)
- JSON-LD schema updates (
SoftwareApplication+FAQPage+BreadcrumbList) - Manual testing: JS syntax validation, language toggle verification, mobile responsive check
- Service Worker cache version bump
I won't rush it. The architecture is solid. The lesson from Live Photo Frame is clear: ship what you're proud of, even if that means shipping less.
The next batch will likely be the remaining PDF tools and video converters. Each shared library (convert-image.js, compress-image.js) reduces the per-page migration cost — the shared t() calls are already defined; only the HTML markup needs updating.
The Ask
If you've ever built something, shipped it, then had to kill it after two days of debugging — you're not alone.
If you've ever stared at an AI token bill and wondered if the architecture is worth the arithmetic — same.
If you've ever shipped code from a hospital waiting room, or debugged a production bug at 3 AM while your body begged for sleep — I see you.
ToolKnit is free. No signup. No uploads. All processing is client-side. Read the full changelog — it's more honest than most build logs.
Built with love. Paid in tokens. Still shipping.
Originally published as a raw build log from a solo indie project. No affiliate links. No sponsor reads. Just code, costs, hospital visits, and the occasional 3 AM SyntaxError. If you're building something alone right now — keep going. The grey skies pass. They always do.
Top comments (0)