---
title: 45 Tabs I Stopped Opening
url: https://varstatt.com/jurij/p/45-tabs-i-stopped-opening
author: Jurij Tokarski
date: 2026-04-09
description: A JWT decoder, a mesh gradient engine, an animation system, and everything in between. Three of them outgrew the toolkit.
section: Blog (https://varstatt.com/jurij/archive)
tags: project-stories (https://varstatt.com/jurij/c/project-stories)
---

The JWT decoder I used to reach for sent the token to a server. I noticed because I had DevTools open for something else and saw the POST. A JWT often carries user IDs, emails, roles, expiration data. I'd been pasting production tokens into a stranger's endpoint for months.

That was the first tool I built for the [toolkit](/toolkit). The rest followed the same pattern: I needed something, the available options were ad-heavy or required sign-up or made network calls that didn't need to happen. A Base64 encoder doesn't need a backend. Neither does a regex tester, a color converter, or a hash generator.

There are 45 tools now (48 as of mid-2026). No sign-up, no tracking, no data collection. Most run entirely in the browser — a few like DNS Lookup and SSL Checker need a server call by nature.

## The Catalogue

**Encoding** — [Base64](/toolkit/base64), [JWT Decoder](/toolkit/jwt), [Image to Base64](/toolkit/img2b64), [Encrypt / Decrypt](/toolkit/encrypt), [Hash Generator](/toolkit/hash)

**JSON & YAML** — [JSON Formatter](/toolkit/json), [JSON ↔ YAML](/toolkit/json-yaml), [YAML Validator](/toolkit/yaml)

**Markdown** — [Markdown Preview](/toolkit/md), [Text Diff](/toolkit/diff), [HTML ↔ Markdown](/toolkit/html-md), [Markdown to PDF](/toolkit/md-pdf), [Markdown to DOCX](/toolkit/md-docx), [CSV Editor](/toolkit/csv)

**Images** — [QR Code](/toolkit/qr), [Barcode](/toolkit/barcode), [Image Converter](/toolkit/convert), [Favicon Generator](/toolkit/favicon), [SVG Optimizer](/toolkit/svg), [Placeholder Images](/toolkit/placeholder), [Aspect Ratio](/toolkit/ratio)

**Design** — [Mesh Gradient](/toolkit/mesh), [CSS Cover Art](/toolkit/covers), [Color Converter](/toolkit/color), [Text to Gradient](/toolkit/text-gradient)

**Charts** — [Bar Chart Race](/toolkit/chart-bar), [Line Chart Race](/toolkit/chart-line), [Bubble Chart Race](/toolkit/chart-bubble), [Area Chart Race](/toolkit/chart-area)

**Network** — [DNS Lookup](/toolkit/dns), [CORS Tester](/toolkit/cors), [SSL Checker](/toolkit/ssl), [OG Tag Validator](/toolkit/og), [HTTP Status Codes](/toolkit/http), [Robots.txt Validator](/toolkit/robots), [Sitemap Validator](/toolkit/sitemap), [User Agent Parser](/toolkit/ua)

**Text** — [Regex Tester](/toolkit/regex), [Case Converter](/toolkit/case), [Slug Generator](/toolkit/slug), [Word Counter](/toolkit/words), [Copy Paste Characters](/toolkit/chars)

**Generators** — [UUID](/toolkit/uuid), [Password](/toolkit/password), [Crontab](/toolkit/crontab), [Unix Timestamp](/toolkit/timestamp)

Most are straightforward. Three outgrew the toolkit and became standalone npm packages.

## Text to Gradient

The [Text to Gradient](/toolkit/text-gradient) tool and the [Mesh Gradient Generator](/toolkit/mesh) both needed the same thing: a way to turn an arbitrary input into a unique, stable visual. Same input, same gradient, every time. No database, no storage.

A djb2-style 32-bit hash is all it takes:

```js
function textHash(str) {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) + hash) + str.charCodeAt(i);
    hash = hash >>> 0;
  }
  return hash;
}
```

Everything derives from that number. `hash % palettes.length` selects the color palette. `seededRandom(hash + layerIndex * 1000)` generates position and opacity variation per layer. The same string always produces the same gradient — looks hand-crafted, costs nothing to store.

The gradients themselves are layered `radial-gradient()` calls. There's no `mesh-gradient()` in CSS. What works is stacking 6-8 radial gradients positioned at organic spots — 15%, 37%, 63%, 82% — not pure corners or centers, which look algorithmic. Each one uses a `0px` first stop for a crisp center and `transparent` at 50% for soft falloff. The browser composites them in layer order.

```css
background:
  radial-gradient(ellipse at 15% 20%, rgba(120, 40, 200, 0.9) 0px, transparent 60%),
  radial-gradient(circle at 80% 10%, rgba(40, 180, 220, 0.8) 0px, transparent 50%),
  radial-gradient(ellipse at 55% 75%, rgba(200, 60, 120, 0.85) 0px, transparent 55%),
  #1a0a2e;
```

For tinting — hover states, borders, soft fills — `color-mix()` handles it without any HSL arithmetic:

```css
background-color: color-mix(in srgb, var(--accent) 12%, white);
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
```

One thing that cost me time: making these dynamic in Tailwind. A template literal like `` bg-[color-mix(in_srgb,${color}_12%,white)] `` silently produces nothing. Tailwind's compiler scans source files for complete static strings at build time. A class assembled from a variable doesn't exist as a string when the scanner runs — it gets skipped with no warning. Inline styles are the fallback for truly dynamic values.

Text to Gradient is now an [npm package](https://www.npmjs.com/package/text-to-gradient). It powers the default cover images across the site when a page has no custom visual. Those covers are also animated — which is where the next package came from.

## Loopkit

Every tool, blog post, landing page, and discovery step on varstatt.com has an animated SVG cover — all powered by [Loopkit](/toolkit/loopkit). I had ~35 cover designs already in JSX when I started building the engine underneath them. The first decision was whether to keep composable React components or switch to schema-driven JSON.

JSON won because of output flexibility. A React component locks you into JSX. A schema is data — it can render to HTML for OG images, to SVG for exports, to CSS for emails, or to React for the live site. The core engine has no React dependency.

```javascript
const cover = createCover(schema);
cover.html       // full HTML with inline styles
cover.style      // React style objects
cover.innerHtml  // just the elements
cover.hoverCss   // raw CSS rules
```

**Phase ordering.** I had the cycle structured as: animate forward, hold final frame, fade out, loop. Loop restarts were smooth, but the first `play()` call snapped instantly from the held frame to frame 0. Moving the fade to the beginning of the cycle fixed it — every iteration, including the first, starts with a reverse interpolation from wherever the animation sits, then plays forward.

**Hover exits.** `mouseenter` called `play()`, `mouseleave` called `reset()`. The reset snapped to the static frame — functional but mechanical. A `settle()` method reads the live position and interpolates smoothly from there to the end state over a capped duration. The key: tracking `currentAnimElapsed` during active animation is what makes settle() possible. Without it, mouseleave can only snap.

**Stagger math.** In a staggered loop where each element has its own delay, the cycle duration isn't `animDuration`. It's the time until the last element finishes, plus hold time. Using just `animDuration` cuts off late-starting elements before they complete.

```ts
let lastFinish = 0;
for (const el of schema.elements) {
  const delay = computeDelay(el.animate.sequence ?? 0, schema.stagger ?? 0);
  const duration = el.animate.duration ?? schema.duration ?? 1;
  lastFinish = Math.max(lastFinish, delay + duration);
}
const cycleDuration = lastFinish + holdDuration;
```

Re-centering all 48 schemas programmatically surfaced one more problem. The centering script computes a bounding box, then shifts coordinates to align with the canvas center. Loopkit schemas use `[from, to]` arrays for animated values — a bar animates with `y: [247, 87]`. The bbox script was reading `[0]`, the start value. A bar starting at y=247 with height 180 gave a 427px bounding box on a 280px canvas. The fix was one index: read `[1]`, the end state, because that's the visual rest position.

Loopkit is under 5KB with zero dependencies. It's an [npm package](https://www.npmjs.com/package/loopkit) now.

## Markdown Repository

[Markdown Repository](/toolkit/markdown-repository) began as a utility function inside this site. I query `.md` and `.mdx` files by frontmatter — filter by tags, sort by date, paginate. The API looks like Firestore's `where`/`orderBy`/`limit` chain. Once three of my projects used the same copy-pasted code, I extracted it into an [npm package](https://www.npmjs.com/package/markdown-repository). The publish pipeline — trusted publishing with OIDC, no stored tokens — turned into [its own post](/jurij/p/npm-trusted-publishing-from-github-actions).

## The Full List

45 tools, three npm packages. The full list is at [varstatt.com/toolkit](/toolkit).
